From 81743b7fbc86c67fb4203130c5155d2043967f54 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:06:26 +0000 Subject: [PATCH 01/18] feat(smithers): seed durable workflow layer with autonomous advisor arch gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed rig's second execution surface: Smithers workflow equivalents of the rig-epic/rig-task skills, so autonomous runs can enforce what skill prose only instructs. First pass of the workflow-layer upstreaming (#12). rig-epic gains an `advisor` input flag (#13): when set, the front-loaded `spec-direction` gate renders as a Fable (`providers.claude`) Task instead of a HumanTask — same node id + {proceed,direction} schema — so a kicked-off epic runs unattended to child PRs, halting with a blocked report on proceed=false instead of parking on a human. Trunk merge stays human. Includes workflows/{rig-epic,rig-task}.tsx, ui/{rig-epic,rig-task}.tsx, a reference agents.example.ts (machine-specific agents.ts is regenerated via `smithers init`, not vendored), and a README documenting the dual-surface parity contract and the remaining #12 work (install.sh vendoring adapter). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Es83TAgTGyhCG9z8rczB4z --- smithers/README.md | 74 +++++ smithers/agents.example.ts | 171 ++++++++++ smithers/ui/rig-epic.tsx | 118 +++++++ smithers/ui/rig-task.tsx | 120 +++++++ smithers/workflows/rig-epic.tsx | 539 ++++++++++++++++++++++++++++++++ smithers/workflows/rig-task.tsx | 509 ++++++++++++++++++++++++++++++ 6 files changed, 1531 insertions(+) create mode 100644 smithers/README.md create mode 100644 smithers/agents.example.ts create mode 100644 smithers/ui/rig-epic.tsx create mode 100644 smithers/ui/rig-task.tsx create mode 100644 smithers/workflows/rig-epic.tsx create mode 100644 smithers/workflows/rig-task.tsx diff --git a/smithers/README.md b/smithers/README.md new file mode 100644 index 0000000..fec9866 --- /dev/null +++ b/smithers/README.md @@ -0,0 +1,74 @@ +# Smithers workflow layer (WIP integration) + +Durable [Smithers](https://smithers.sh) workflow equivalents of the rig skills. +This is rig's **second execution surface**: the `skills/**` prose is what an +interactive agent *follows*; these `.tsx` workflows are what an autonomous +Smithers run *executes*. The graph surface can **enforce** what prose can only +**instruct**. + +> **Status: incubating.** Tracked by [agent-rig/rig#12](https://github.com/agent-rig/rig/issues/12). +> These files were seeded from a working trial; `install.sh` does **not** vendor +> them yet, and the parity contract below is not yet automated. + +## Contents + +| File | Role | +|---|---| +| `workflows/rig-epic.tsx` | Integration-branch epic: preflight → plan → front-loaded Arch/QA spec → **spec gate** → per-child `rig-task` fan-out (Subflow) → combined-diff review → finish (squash PR). | +| `workflows/rig-task.tsx` | One unit of work: preflight → setup → spec review → RED → GREEN loop → refactor → review-find/fix loop → PR → review-bot. | +| `ui/rig-epic.tsx`, `ui/rig-task.tsx` | The `` dashboards (`smithers ui `). | +| `agents.example.ts` | **Reference only** — a machine-generated `agents.ts`. See "Consuming project" below. | + +## The advisor gate (autonomous arch gate) + +Tracked by [agent-rig/rig#13](https://github.com/agent-rig/rig/issues/13). + +`rig-epic` takes an `advisor` input flag. By default the front-loaded spec gate +(`spec-direction`) is a `` — a human reads the Architect + QA specs, +types free-form direction, and sets `proceed`. With **`advisor: true`** that same +node renders as a Fable (`providers.claude`) `` instead: it reads the specs, +then either + +- `proceed: true` → synthesizes the per-child `direction` and fans out unattended, or +- `proceed: false` → the epic **halts with a blocked report** (no human waits). + +Same node id + `{ proceed, direction }` schema as the human gate, so everything +downstream is unchanged. This lets parallel epics run to child PRs without +parking on a human gate. The trunk merge stays human (finish stops at an open PR +unless `--merge`). + +## Consuming project (what these workflows assume) + +They are authored for a project that has run `smithers init` and therefore has a +`.smithers/` package (`smithers-orchestrator`, `bunfig.toml`, `preload.ts`, a +`smithers.config.ts` with `repoCommands`). Two hard dependencies: + +1. **`../agents` must export `providers`** with at least the pools the ROLE maps + reference — `claude` (Fable, used by the advisor + fallbacks), `claudeOpus` + (architect/reviewer), `claudeSonnet` (qa/coder/coord), plus the codex pools in + `agents.example.ts`. `agents.ts` is **machine-specific** (generated from + `~/.smithers/accounts.json` via `smithers agents add`), so it is NOT vendored — + regenerate it per environment. `agents.example.ts` here shows the exact pool + names + model wiring the advisor and ROLE maps expect. +2. `rig-epic` loads `rig-task` by relative path (`RIG_TASK_REF`) and reads + `.rig/config.json` + `.rig/epics/*.json`, i.e. it expects the rig skills + installed alongside. + +## Parity contract + +rig has two surfaces that must not drift: + +- **Shared artifacts** both read: `templates/REVIEWER.md`, the repo's justfile + command names. Change once, both surfaces honor it. +- **Skill prose** (`skills/**`) — instructed, for interactive runs. +- **Workflow graph** (this dir) — enforced, for autonomous runs. + +A behavior change to a skill should have a matching graph change here, and vice +versa. Where a check can be *enforced* (a real gate/command), the graph is the +source of truth; the skill prose mirrors it as guidance. + +## TODO (#12) + +- [ ] `install.sh`: add a `smithers` target adapter vendoring `smithers/{workflows,ui}` → `/.smithers/{workflows,ui}`. +- [ ] Decide handling of `agents.ts` / `smithers.config.ts` on install (delegate to `smithers init`, or ship a template). +- [ ] Automate/verify the parity contract. diff --git a/smithers/agents.example.ts b/smithers/agents.example.ts new file mode 100644 index 0000000..8353d5c --- /dev/null +++ b/smithers/agents.example.ts @@ -0,0 +1,171 @@ +// smithers-source: generated +import { type AgentLike } from "smithers-orchestrator"; +import { ClaudeCodeAgent as SmithersClaudeCodeAgent } from "smithers-orchestrator"; +import { CodexAgent as SmithersCodexAgent } from "smithers-orchestrator"; +import { PiAgent as SmithersPiAgent } from "smithers-orchestrator"; +// import { OpenAIAgent as SmithersOpenAIAgent } from "smithers-orchestrator"; +// import { OpenCodeAgent as SmithersOpenCodeAgent } from "smithers-orchestrator"; +// import { AntigravityAgent as SmithersAntigravityAgent } from "smithers-orchestrator"; +// import { OmpAgent as SmithersOmpAgent } from "smithers-orchestrator"; +// import { KimiAgent as SmithersKimiAgent } from "smithers-orchestrator"; +// import { AmpAgent as SmithersAmpAgent } from "smithers-orchestrator"; +// import { VibeAgent as SmithersVibeAgent } from "smithers-orchestrator"; +// import { HermesCliAgent as SmithersHermesCliAgent } from "smithers-orchestrator"; +// import { OpenClawAgent as SmithersOpenClawAgent } from "smithers-orchestrator"; +// import { PoolAgent as SmithersPoolAgent } from "smithers-orchestrator"; + +export { ClaudeCodeAgent } from "./agents/claude-code"; +export { CodexAgent } from "./agents/codex"; +// export { OpenCodeAgent } from "./agents/opencode"; +// export { AntigravityAgent } from "./agents/antigravity"; +// export { PoolAgent } from "./agents/pool"; + +// class SmithersOpenRouterAgent extends SmithersOpenAIAgent { +// generate(args = {}) { +// if (!process.env.OPENROUTER_API_KEY) { +// throw new Error("Smithers generated an OpenRouter default agent, but OPENROUTER_API_KEY is not set. Set OPENROUTER_API_KEY, or run `smithers agent add` to configure another agent, then rerun this workflow."); +// } +// return super.generate(args); +// } +// } +// +// function createOpenRouterAgent() { +// return new SmithersOpenRouterAgent({ +// model: "openai/gpt-5.4-mini", +// baseURL: "https://openrouter.ai/api/v1", +// apiKey: process.env.OPENROUTER_API_KEY, +// }); +// } + +export const providers = { + claude: new SmithersClaudeCodeAgent({ model: "claude-fable-5" }), + codex: new SmithersCodexAgent({ model: "gpt-5.6-luna", config: { model_reasoning_effort: "medium" }, skipGitRepoCheck: true }), +// openrouter: createOpenRouterAgent(), +// opencode: new SmithersOpenCodeAgent({ model: "anthropic/claude-fable-5" }), +// antigravity: new SmithersAntigravityAgent(), + pi: new SmithersPiAgent({ provider: "openai", model: "gpt-5.6-luna" }), +// omp: new SmithersOmpAgent({ model: "gpt-5.6-luna" }), +// kimi: new SmithersKimiAgent({ model: "kimi-k2.7-code" }), +// amp: new SmithersAmpAgent(), +// vibe: new SmithersVibeAgent({ agent: "auto-approve" }), +// hermes: new SmithersHermesCliAgent(), +// openclaw: new SmithersOpenClawAgent(), +// pool: new SmithersPoolAgent(), + codexSol: new SmithersCodexAgent({ model: "gpt-5.6-sol", config: { model_reasoning_effort: "xhigh" }, skipGitRepoCheck: true }), + codexTerra: new SmithersCodexAgent({ model: "gpt-5.6-terra", config: { model_reasoning_effort: "medium" }, skipGitRepoCheck: true }), + codexLuna: new SmithersCodexAgent({ model: "gpt-5.6-luna", config: { model_reasoning_effort: "medium" }, skipGitRepoCheck: true }), + claudeOpus: new SmithersClaudeCodeAgent({ model: "claude-opus-4-8" }), + claudeSonnet: new SmithersClaudeCodeAgent({ model: "claude-sonnet-5" }), +} as const; + +export const agents = { + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + cheapFast: [ + providers.codexLuna, + providers.claudeSonnet, + providers.pi, + // providers.kimi, + // providers.vibe, + // providers.antigravity, + // providers.openclaw, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + research: [ + providers.codexLuna, + providers.claudeSonnet, + // providers.kimi, + // providers.antigravity, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + implement: [ + providers.codexTerra, + providers.claudeSonnet, + providers.claude, + // providers.kimi, + // providers.antigravity, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + midTier: [ + providers.codexTerra, + providers.claudeSonnet, + providers.claude, + // providers.kimi, + // providers.antigravity, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + smartTool: [ + providers.codexTerra, + providers.claudeSonnet, + providers.claude, + // providers.kimi, + // providers.antigravity, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + validate: [ + providers.codexTerra, + providers.claudeSonnet, + providers.claude, + // providers.kimi, + // providers.antigravity, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + smart: [ + providers.codexSol, + providers.claude, + providers.claudeOpus, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + // providers.antigravity, + // providers.amp, + // providers.kimi, + ], + // Codex runs first. Later entries are runtime fallbacks and are invoked only if every Codex attempt fails. + review: [ + providers.codexSol, + providers.claude, + providers.claudeOpus, + providers.claudeSonnet, + // providers.kimi, + // providers.amp, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Claude leads this seat (Codex 5.6 does not orchestrate or gate). Later entries, including Codex, are runtime fallbacks. + planning: [ + providers.claude, + providers.claudeOpus, + providers.codexSol, + providers.claudeSonnet, + // providers.kimi, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], + // Claude leads this seat (Codex 5.6 does not orchestrate or gate). Later entries, including Codex, are runtime fallbacks. + orchestrator: [ + providers.claudeOpus, + providers.claude, + providers.codexSol, + // providers.kimi, + // providers.opencode, + // providers.openclaw, + // providers.openrouter, + ], +} as const satisfies Record; diff --git a/smithers/ui/rig-epic.tsx b/smithers/ui/rig-epic.tsx new file mode 100644 index 0000000..92a3616 --- /dev/null +++ b/smithers/ui/rig-epic.tsx @@ -0,0 +1,118 @@ +/** @jsxImportSource react */ +import { useState } from "react"; +import type { CSSProperties } from "react"; +import { createGatewayReactRoot, useGatewayRuns } from "smithers-orchestrator/gateway-react"; +import { + ApprovalPanel, + ConnectionBadge, + LaunchButton, + MonitorButton, + NodeChatStream, + NodeOutputView, + RunList, + RunMeta, + RunTree, + WorkflowUiShell, +} from "smithers-orchestrator/gateway-ui"; + +const WORKFLOW = "rig-epic"; + +/** The canonical rig-epic arc, mirrored from the workflow graph. */ +const STEPS: Array<[string, string]> = [ + ["epic-preflight", "Resolve config + active epic state file"], + ["plan", "Decompose feature → parent + children (blockedBy)"], + ["start", "Cut integration branch · write state file"], + ["approve-run", "Gate — execution is an explicit opt-in"], + ["child-* → merge-*", "Each child via rig-task, stacked, then merge-gated"], + ["review-lenses", "Combined diff: simplify · cross-PR · dead-code"], + ["review-consolidate", "One P0/P1/P2 list; apply-or-pause gate"], + ["approve-finish", "Gate before the squash-to-trunk PR"], + ["finish-squash", "Squash PR to the trunk (merge only with --merge)"], + ["epic-result", "Report — never auto-merges the trunk"], +]; + +const border = "1px solid rgba(127,127,127,0.25)"; +const cardStyle: CSSProperties = { border, borderRadius: 8, padding: 12 }; + +function runIdFromUrl(): string | undefined { + if (typeof location === "undefined") return undefined; + return new URLSearchParams(location.search).get("runId") ?? undefined; +} + +function ArcLegend() { + return ( +
+

Arc · plan → run → review → finish

+
    + {STEPS.map(([id, desc]) => ( +
  1. + {id} + — {desc} +
  2. + ))} +
+
+ ); +} + +function App() { + const [runId, setRunId] = useState(runIdFromUrl()); + const [nodeId, setNodeId] = useState(); + + const latest = useGatewayRuns({ filter: { workflow: WORKFLOW, limit: 1 } }); + const latestData = latest.data as { runs?: Array<{ runId?: string }> } | Array<{ runId?: string }> | undefined; + const latestRunId = Array.isArray(latestData) ? latestData[0]?.runId : latestData?.runs?.[0]?.runId; + const activeRunId = runId ?? latestRunId; + + return ( + } + actions={ +
+ + + Launch rig-epic + + +
+ } + > + {/* Pending approvals (run/review/finish gates) — first thing you see. */} +
+

⚠ Approvals & blockers

+ +
+ +
+
+ { + setRunId(id); + setNodeId(undefined); + }} + /> + +
+ + setNodeId(node.id)} /> + +
+ + +
+
+
+ ); +} + +createGatewayReactRoot(); diff --git a/smithers/ui/rig-task.tsx b/smithers/ui/rig-task.tsx new file mode 100644 index 0000000..691afc5 --- /dev/null +++ b/smithers/ui/rig-task.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource react */ +import { useState } from "react"; +import type { CSSProperties } from "react"; +import { createGatewayReactRoot, useGatewayRuns } from "smithers-orchestrator/gateway-react"; +import { + ApprovalPanel, + ConnectionBadge, + LaunchButton, + MonitorButton, + NodeChatStream, + NodeOutputView, + RunList, + RunMeta, + RunTree, + WorkflowUiShell, +} from "smithers-orchestrator/gateway-ui"; + +const WORKFLOW = "rig-task"; + +/** The canonical rig-task pipeline, mirrored from the workflow graph. */ +const STEPS: Array<[string, string]> = [ + ["preflight", "Resolve unit + .rig config"], + ["setup", "Load spec · set up worktree"], + ["spec-architect · spec-qa", "Spec review (parallel)"], + ["approve-spec", "Blocker gate — pauses only if flagged"], + ["red-step", "RED — failing tests first"], + ["green-step", "GREEN — minimum impl (loop ×3)"], + ["refactor-step", "REFACTOR — only while green"], + ["review-find · review-fix", "Pre-PR self-review (loop)"], + ["open-pr", "Push + open PR"], + ["review-bot", "Review-bot loop (finish)"], + ["result", "Hand back — never auto-merges"], +]; + +const border = "1px solid rgba(127,127,127,0.25)"; +const cardStyle: CSSProperties = { border, borderRadius: 8, padding: 12 }; + +function runIdFromUrl(): string | undefined { + if (typeof location === "undefined") return undefined; + return new URLSearchParams(location.search).get("runId") ?? undefined; +} + +function PipelineLegend({ label }: { label: string }) { + return ( +
+

Pipeline · {label}

+
    + {STEPS.map(([id, desc]) => ( +
  1. + {id} + — {desc} +
  2. + ))} +
+
+ ); +} + +function App() { + const [runId, setRunId] = useState(runIdFromUrl()); + const [nodeId, setNodeId] = useState(); + + // Follow the newest run when the URL didn't pin one. + const latest = useGatewayRuns({ filter: { workflow: WORKFLOW, limit: 1 } }); + const latestData = latest.data as { runs?: Array<{ runId?: string }> } | Array<{ runId?: string }> | undefined; + const latestRunId = Array.isArray(latestData) ? latestData[0]?.runId : latestData?.runs?.[0]?.runId; + const activeRunId = runId ?? latestRunId; + + return ( + } + actions={ +
+ + + Launch rig-task + + +
+ } + > + {/* Pending approvals (spec-review blocker gate, etc.) — first thing you see. */} +
+

⚠ Approvals & blockers

+ +
+ +
+
+ { + setRunId(id); + setNodeId(undefined); + }} + /> + +
+ + setNodeId(node.id)} /> + +
+ + +
+
+
+ ); +} + +createGatewayReactRoot(); diff --git a/smithers/workflows/rig-epic.tsx b/smithers/workflows/rig-epic.tsx new file mode 100644 index 0000000..28f168b --- /dev/null +++ b/smithers/workflows/rig-epic.tsx @@ -0,0 +1,539 @@ +// smithers-source: seeded +// smithers-metadata-version: 1 +// smithers-display-name: rig-epic — integration-branch workflow +// smithers-description: Canonicalizes the /rig-epic skill as a durable graph — decompose a feature into parent + children, stack each child PR (via the rig-task sub-workflow) on a shared integration branch, review the combined diff across three lenses, then squash to the trunk. Never auto-merges the trunk without opt-in. +// smithers-tags: rig, epic, integration-branch, stacked-prs +/** @jsxImportSource smithers-orchestrator */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createSmithers, Subflow, HumanTask } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../agents"; + +/** + * Role -> model, matched to .claude/agents/rig-*.md (architect/reviewer = opus, + * qa/coder = sonnet). Single Claude model per role, NOT the codex/fable-leading + * pools in agents.ts. `coord` = orchestration steps with no rig role (git/gh, + * state file, merge gate) -> conservative sonnet. + */ +const ROLE = { + architect: providers.claudeOpus, + reviewer: providers.claudeOpus, + qa: providers.claudeSonnet, + coder: providers.claudeSonnet, + coord: providers.claudeSonnet, + // `advisor` = the autonomous arch gate: Fable stands in for the human at the + // front-loaded spec gate so a kicked-off epic runs unattended (see `advisor` input). + advisor: providers.claude, +} as const; + +// Reference the rig-task workflow by file (resolved relative to THIS module, so +// it is cwd-independent). Loaded from the approved root when a child node runs. +const WORKFLOWS_DIR = new URL(".", import.meta.url).pathname; +const RIG_TASK_REF = { + path: resolve(WORKFLOWS_DIR, "rig-task.tsx"), + approvedRoot: resolve(WORKFLOWS_DIR, ".."), +}; + +/** + * Canonical graph of the /rig-epic skill (.claude/skills/rig-epic/SKILL.md). + * + * epic-preflight + * plan -> start (phase plan|full: steps `plan` + `start`) + * (approve-run gate, full only) + * run: for each child in dependency (topological) order — + * Subflow(rig-task --base ) -> merge-gate (phase run|full) + * review: [simplify | cross-pr | dead-code] -> consolidate -> approve (phase review|full) + * finish: review gate -> squash PR -> (optional --merge) (phase finish|full) + * + * Each child is the rig-task workflow run against the integration branch, so + * the two canonicalized skills compose exactly as /rig-epic delegates to + * /rig-task in prose. + */ + +const childSchema = z.object({ + id: z.string(), + title: z.string(), + blockedBy: z.array(z.string()).default([]), + status: z.string().default("todo"), +}); +type Child = z.infer; + +const inputSchema = z.object({ + phase: z + .enum(["plan", "run", "review", "finish", "full"]) + .default("full") + .describe("full (default) = the whole arc plan→run→review→finish as ONE durable run, pausing only at in-graph approval gates. plan/run/review/finish are partial entry points; finish always runs the review gate first. An existing epic (parent + state, no feature) skips planning and resumes from what's already merged."), + feature: z.string().default("").describe("The feature to decompose (phase plan/full)."), + parent: z.string().default("").describe("Parent id or integration branch to resume from (phase run/review/finish). Empty = infer the single active epic."), + merge: z.boolean().default(false).describe("finish --merge: squash-merge the final PR to the trunk instead of stopping at an open PR."), + advisor: z + .boolean() + .default(false) + .describe("Autonomous arch gate. When true, the front-loaded spec gate (spec-direction) is decided by a Fable advisor instead of a human: it reads the architect+QA specs, then either proceed=true with synthesized per-child direction, or proceed=false → the epic HALTS with a blocked report (no human ever waits). Lets parallel epics run unattended to child PRs."), +}); + +const epicResultSchema = z.object({ + phase: z.string(), + integrationBranch: z.string(), + prUrl: z.string(), + summary: z.string(), +}); + +// Mirror of rig-task's designated result — the shape Subflow persists per child. +const childRunSchema = z.object({ + outcome: z.string(), + unit: z.string(), + prUrl: z.string(), + testsGreen: z.boolean(), + reviewState: z.string(), + summary: z.string(), +}); + +const { Workflow, Sequence, Parallel, Task, Branch, Approval, UI, smithers, outputs } = createSmithers({ + input: inputSchema, + output: epicResultSchema, + epicPreflight: z.object({ + baseRef: z.string(), + defaultBranch: z.string(), + trackerProvider: z.string(), + integrationBranch: z.string(), + parent: z.string(), + whyEpic: z.string(), + childrenJson: z.string(), + source: z.string(), + banner: z.string(), + }), + plan: z.object({ + parent: z.string(), + parentTitle: z.string(), + integrationBranch: z.string(), + whyEpic: z.string(), + childrenJson: z.string(), + summary: z.string(), + }), + start: z.object({ integrationBranch: z.string(), stateFile: z.string(), summary: z.string() }), + specDirection: z.object({ proceed: z.boolean(), direction: z.string() }), + epicSpec: z.object({ role: z.string(), blockers: z.array(z.string()), notes: z.string() }), + childRun: childRunSchema, + merge: z.object({ childId: z.string(), merged: z.boolean(), prUrl: z.string(), detail: z.string() }), + reviewLens: z.object({ lens: z.string(), p0p1: z.number().int(), p2: z.number().int(), findings: z.string() }), + reviewConsolidated: z.object({ p0p1: z.number().int(), p2: z.number().int(), clean: z.boolean(), report: z.string() }), + reviewApproval: z.object({ approved: z.boolean() }), + reviewFix: z.object({ summary: z.string() }), + finishApproval: z.object({ approved: z.boolean() }), + squashPr: z.object({ number: z.number().int(), url: z.string() }), + epicResult: epicResultSchema, +}); + +const SKILL = ".claude/skills/rig-epic/SKILL.md"; + +/** Kahn topological sort over blockedBy edges; falls back to declaration order on a cycle. */ +function topoOrder(children: Child[]): Child[] { + const byId = new Map(children.map((c) => [c.id, c])); + const indeg = new Map(children.map((c) => [c.id, 0])); + for (const c of children) { + for (const dep of c.blockedBy) { + if (byId.has(dep)) indeg.set(c.id, (indeg.get(c.id) ?? 0) + 1); + } + } + const queue = children.filter((c) => (indeg.get(c.id) ?? 0) === 0); + const ordered: Child[] = []; + const seen = new Set(); + while (queue.length) { + const c = queue.shift()!; + if (seen.has(c.id)) continue; + seen.add(c.id); + ordered.push(c); + for (const other of children) { + if (other.blockedBy.includes(c.id)) { + indeg.set(other.id, (indeg.get(other.id) ?? 0) - 1); + if ((indeg.get(other.id) ?? 0) === 0) queue.push(other); + } + } + } + return ordered.length === children.length ? ordered : children; +} + +function parseChildren(json: string | undefined): Child[] { + if (!json) return []; + try { + const raw = JSON.parse(json); + return Array.isArray(raw) ? raw.map((r) => childSchema.parse({ blockedBy: [], ...r })) : []; + } catch { + return []; + } +} + +export default smithers((ctx) => { + const { phase, merge, advisor } = ctx.input; + const isFull = phase === "full"; + // `full` is the continuous default: it drives plan→run→review→finish, pausing + // ONLY at in-graph approval gates (no agent stitching phases together). + // Plan only when a feature was given; an existing epic (parent + state) skips + // straight to run/review/finish based on what's already merged. + const doPlan = (phase === "plan" || isFull) && ctx.input.feature.trim() !== ""; + const doRun = phase === "run" || isFull; + const doReview = phase === "review" || phase === "finish" || isFull; // review folds INTO finish + const doFinish = phase === "finish" || isFull; + + const pre = ctx.outputMaybe(outputs.epicPreflight, { nodeId: "epic-preflight" }); + const planRow = ctx.outputMaybe(outputs.plan, { nodeId: "plan" }); + const startRow = ctx.outputMaybe(outputs.start, { nodeId: "start" }); + + // The integration branch + children come from the plan (fresh) or the state file (existing epic). + const integrationBranch = planRow?.integrationBranch || pre?.integrationBranch || ""; + const children = topoOrder(parseChildren(planRow?.childrenJson ?? pre?.childrenJson)); + + // A child counts as merged if the state file already says so, or its run-loop merge node recorded it. + const merged = (c: Child) => c.status === "merged" || ctx.outputMaybe(outputs.merge, { nodeId: `merge-${c.id}` })?.merged === true; + const allMerged = children.length > 0 && children.every(merged); + const startedFresh = doPlan; // a fresh plan cuts the branch first + + // FRONT-LOADED SPEC REVIEW: review ALL children's specs up front (architect + qa), + // gate on ONE approval, then run children with their own spec gate OFF. A child + // must never pause mid-run — a paused Subflow child fails the whole epic. + const specArch = ctx.outputMaybe(outputs.epicSpec, { nodeId: "epic-spec-architect" }); + const specQa = ctx.outputMaybe(outputs.epicSpec, { nodeId: "epic-spec-qa" }); + const specReviewed = Boolean(specArch && specQa); + const specBlockers = [...(specArch?.blockers ?? []), ...(specQa?.blockers ?? [])]; + const specHasBlockers = specBlockers.length > 0; + // The spec gate produces free-form direction (not just approve/deny): a human + // (HumanTask) by default, or a Fable advisor (Task) when `advisor` is set for + // unattended runs. Either way `direction` is threaded into every child's coder; + // `proceed` gates execution (false = halt with a blocked report). + const specDir = ctx.outputMaybe(outputs.specDirection, { nodeId: "spec-direction" }); + const specAnswered = Boolean(specDir); + const runApproved = specDir?.proceed === true; + const direction = specDir?.direction ?? ""; + // Spec review can run once the branch + children are known (start done for a fresh epic). + const specReviewReady = doRun && integrationBranch !== "" && children.length > 0 && !allMerged && (!startedFresh || Boolean(startRow)); + // Children execute only after the human answers the spec gate with proceed=true. + const readyToRun = specReviewReady && specReviewed && runApproved; + + const consolidated = ctx.outputMaybe(outputs.reviewConsolidated, { nodeId: "review-consolidate" }); + const reviewClean = consolidated?.clean === true; + const reviewApproved = ctx.outputMaybe(outputs.reviewApproval, { nodeId: "approve-review" })?.approved === true; + // Review runs once all children are in (or immediately for an already-merged epic). + const reviewReady = doReview && integrationBranch !== "" && allMerged; + + const finishGatePassed = reviewClean || reviewApproved; // review is a HARD gate before the squash PR + const finishApproved = isFull ? ctx.outputMaybe(outputs.finishApproval, { nodeId: "approve-finish" })?.approved === true : true; + const finishReady = doFinish && finishGatePassed && (!isFull || finishApproved); + + const cd = integrationBranch ? `The integration branch is \`${integrationBranch}\`.` : ""; + + return ( + + + + {/* Resolve config + (for run/review/finish) the active epic state file. */} + + {async () => { + const def = { baseRef: "origin/main", defaultBranch: "main", trackerProvider: "none" }; + let cfg: any = {}; + try { + cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); + } catch { + /* unconfigured fallback */ + } + // For run/review/finish, read the epic state file. Search THIS worktree and + // (fallback) the main checkout, since /rig-epic may have been run elsewhere. + let integrationBranch = ""; + let parent = ctx.input.parent; + let whyEpic = ""; + let childrenJson = "[]"; + let source = "none"; + const epicDirs = [resolve(process.cwd(), ".rig/epics")]; + try { + const { execSync } = await import("node:child_process"); + // The main worktree's .git parent → its .rig/epics. + const commonGit = execSync("git rev-parse --path-format=absolute --git-common-dir", { encoding: "utf8" }).trim(); + const mainRepo = resolve(commonGit, ".."); + const mainEpics = resolve(mainRepo, ".rig/epics"); + if (!epicDirs.includes(mainEpics)) epicDirs.push(mainEpics); + } catch { + /* not a git repo / git missing — cwd dir only */ + } + try { + const { readdirSync, existsSync } = await import("node:fs"); + for (const dir of epicDirs) { + if (!existsSync(dir)) continue; + const files = (readdirSync(dir) as string[]).filter((f) => f.endsWith(".json")); + // A NAMED parent only matches a state file that references it (case-insensitive: + // parent may be the ticket id `CEX-553` or the branch slug `cex-553-…`). Never + // fall back to "the single existing file" when a parent is named — that would + // hijack a DIFFERENT epic's state (e.g. two epics running in parallel). + const needle = ctx.input.parent.trim().toLowerCase(); + const pick = needle + ? files.find((f) => f.toLowerCase().includes(needle)) + : files.length === 1 + ? files[0] + : undefined; + if (pick) { + const state = JSON.parse(readFileSync(resolve(dir, pick), "utf8")); + integrationBranch = state.integrationBranch ?? pick.replace(/\.json$/, ""); + parent = state.parent ?? parent; + whyEpic = state.whyEpic ?? ""; + childrenJson = JSON.stringify(state.children ?? []); + source = "state-file"; + break; + } + } + } catch { + /* no state file yet — plan will create one */ + } + // Last resort: no state file, but the caller named the integration branch via `parent`. + if (integrationBranch === "" && ctx.input.parent.trim() !== "") { + integrationBranch = ctx.input.parent.trim(); + source = "input-parent"; + } + const banner = `rig-epic: ${ctx.input.phase} — ${integrationBranch ? `epic ${integrationBranch}` : ctx.input.feature || "(new epic)"}. PRs target ${integrationBranch || "the integration branch"}, not the trunk. Will not auto-merge the trunk without --merge.`; + return { + baseRef: cfg?.vcs?.baseRef ?? def.baseRef, + defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, + trackerProvider: cfg?.tracker?.provider ?? def.trackerProvider, + integrationBranch, + parent, + whyEpic, + childrenJson, + source, + banner, + }; + }} + + + {/* ── plan + start ── */} + {doPlan ? ( + + + {(d) => { + const p = d["epic-preflight"]; + const namedParent = (ctx.input.parent || p.parent || "").trim(); + return `You are executing the \`plan\` step of the /rig-epic skill. Read ${SKILL} and \`.rig/config.json\` first. + +**FIRST decide adopt vs decompose:** +${namedParent ? `A parent is named: \`${namedParent}\`. Fetch it from the tracker (${p.trackerProvider}). If it ALREADY EXISTS and has child issues, **ADOPT — do NOT decompose or create anything**: list its children (Linear: \`list_issues parentId=${namedParent}\`), and return them verbatim as \`childrenJson\` = JSON array of {id, title, blockedBy:[...], status}. Derive \`blockedBy\` from the children's tracker relations, or (if none are set) from the stack order in the parent's description (each later child blocked by the first/foundational one). Set parentTitle from the parent, whyEpic from its description, and propose the integration branch name \`-\` (kebab). Skip steps 1-4 below. If the named parent does NOT exist yet, fall through to decompose.` : `No parent named — decompose the feature below into a new epic.`} + +Otherwise, decompose this feature into a parent + 3-8 children: +"${ctx.input.feature}" + +1. Read product/spec docs and explore the codebase (sourceScope) to see what exists; in a tracker, search for near-duplicate items first. +2. SANITY-CHECK it is genuinely epic-shaped — at least one child's runtime contract depends on another being only partially complete (the interleave test). If the items are independent, STOP and say it should be a /rig-sprint instead (return an empty children list and explain in whyEpic). +3. Create the parent (tracker: ${p.trackerProvider}) and each child with concrete, testable acceptance criteria, small enough for one agent session (1-3 files), foundational work first. +4. Record \`blockedBy\` for EVERY real dependency — this drives execution order. +5. Propose the integration branch name \`-\` (kebab). + +Return: parent (id or slug), parentTitle, integrationBranch, whyEpic, and childrenJson = a JSON array string of {id, title, blockedBy:[...], status}.`; + }} + + + {(d) => { + const pl = d["plan"]; + const p = d["epic-preflight"]; + return `You are executing the \`start\` step of the /rig-epic skill (${SKILL}). Print the intent banner first. + +1. \`git fetch origin\`; confirm the parent and >=1 child exist. +2. Cut the integration branch \`${pl.integrationBranch}\` from \`${p.baseRef}\` WITHOUT a local checkout, and non-destructively (leave it if it already exists): + git push origin ${p.baseRef}:refs/heads/${pl.integrationBranch} +3. Write \`.rig/epics/${pl.integrationBranch}.json\` with { parent, parentTitle, integrationBranch, whyEpic, children:[{id,title,blockedBy,branch:null,status:"todo"}] } using the plan's data: + parent=${pl.parent}; whyEpic=${JSON.stringify(pl.whyEpic)}; children=${pl.childrenJson} + Ensure \`.rig/epics/\` is in .gitignore. +4. In a tracker, add an "Integration branch: target \`${pl.integrationBranch}\`, not the trunk" note to each child, and ensure the PARENT is In Progress (adaptive: \`get_issue\`; if not already started, \`save_issue state="In Progress"\`). Children get their own In Progress from their rig-task Step 1. +5. Name the session "EPIC: ${pl.parentTitle} (${pl.parent})". + +Return integrationBranch, the stateFile path, and a summary. Do NOT start executing children — that is an explicit opt-in.`; + }} + + + ) : null} + + {/* ── front-loaded spec review of ALL children, then ONE approval ── */} + {specReviewReady ? ( + + + + {() => `Front-loaded ARCHITECT spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec from the tracker (${children.map((c) => c.id).join(", ")}) and review each for implementability AGAINST the integration branch (inspect it: \`git fetch origin\`; the terminal service + prior children live on \`${integrationBranch}\`). Flag ambiguities, missing acceptance criteria, and cross-child ordering issues. Return role="architect", notes (per child), and a \`blockers\` array of ONLY things that must be resolved before ANY coding starts — prefix each with the child id (e.g. "CEX-542: gap semantics undefined vs the shared sequencer …").`} + + + {() => `Front-loaded QA spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec (${children.map((c) => c.id).join(", ")}) and review each from a testing perspective against the integration branch. Return role="qa", notes, and a \`blockers\` array of ONLY untestable/contradictory criteria that must be fixed before coding — prefix each with the child id.`} + + + {specReviewed && !specAnswered ? ( + advisor ? ( + // Autonomous arch gate: Fable decides proceed/direction in the human's + // place so a kicked-off epic never parks here. Same node id + schema as + // the HumanTask, so everything downstream (runApproved, per-child + // specNotes, the proceed=false halt) is unchanged. + + {() => `You are the ARCH ADVISOR for the epic on \`${integrationBranch}\` (${SKILL}). ${cd} You stand in for the human at the front-loaded spec gate: BEFORE ${children.length} parallel implementation runs (${children.map((c) => c.id).join(", ")}) commit against the integration branch, you decide whether to proceed and produce the steering direction every child's coder will follow. No human is waiting — you ARE the gate. + +Two independent front-loaded reviews just ran against \`${integrationBranch}\`: +- ARCHITECT — notes: ${JSON.stringify(specArch?.notes ?? "")} +- QA — notes: ${JSON.stringify(specQa?.notes ?? "")} +${ + specHasBlockers + ? `Flagged blocker(s) (${specBlockers.length}):\n- ${specBlockers.join("\n- ")}` + : "Neither reviewer flagged a blocker." +} + +Judge ADVERSARIALLY — a paused child fails the whole epic, and fanning ${children.length} runs out on a bad spec wastes real compute: +- Verify against the branch if useful (\`git fetch origin\`; the terminal service + any prior children live on \`${integrationBranch}\`). +- proceed=true ONLY if the specs are coherent, each child is well-scoped, and there is NO contradiction or missing decision that would make the parallel tasks diverge or need rework. Then set \`direction\` to concrete per-child guidance (prefix each with the child id, e.g. "CEX-543: funding line = rate+countdown") that resolves every flagged item — this is handed verbatim to each child's coder. +- proceed=false if there is a genuine blocker that must be resolved before ANY coding. Put the specific blocking reason(s) and what a human must decide into \`direction\`; the epic HALTS with that as its blocked report. + +Return JSON: {"proceed": , "direction": ""}`} + + ) : ( + c.id).join(", ")}) on \`${integrationBranch}\`.\n\n${ + specHasBlockers + ? `Found ${specBlockers.length} item(s) needing your direction:\n\n- ${specBlockers.join("\n- ")}\n\n` + : "No blockers found.\n\n" + }Type direction that will be handed to EVERY child's coder (address the items above — e.g. "CEX-543: funding line = rate+countdown", "CEX-546: risk panel against RiskFrameSchema fixtures"). Then set proceed=true to execute all children uninterrupted, or proceed=false to halt.\n\nAnswer with JSON, e.g.:\n{"proceed": true, "direction": ""}`} + /> + ) + ) : null} + + ) : null} + + {/* ── run: each child = rig-task against the integration branch, then a merge gate ── */} + {readyToRun + ? (() => { + const lanes: React.ReactElement[] = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (merged(child)) { + continue; // already merged (state file or a prior lane) — its lane is done + } + if (i > 0 && !merged(children[i - 1])) { + break; // previous child not merged yet — stop the chain here + } + const prevId = i > 0 ? children[i - 1].id : undefined; + lanes.push( + + + + {(d) => { + const run = d[`child-${child.id}`]; + return `You are the merge gate for epic child ${child.id} (${SKILL}). ${cd} + +The child's rig-task run finished with outcome "${run.outcome}" (PR ${run.prUrl}) and, if clean, squash-merged it into the integration branch (or armed \`--squash --auto\` if a required check was pending). + +ONLY "clean" is merge-green: +- "clean" → the child PR **squash-merges** into \`${integrationBranch}\` (directly when there are no required checks, else once they pass). Confirm/WAIT: poll \`gh pr view --json state\` (~60s intervals, up to ~30min) until state=MERGED, then \`git fetch origin\` and confirm the integration tip advanced. Then: (a) update \`.rig/epics/${integrationBranch}.json\` — mark ${child.id} status="merged" + record its branch/PR; (b) ensure the child ticket ${child.id} is Done (adaptive — ignore the githubIntegration config flag: \`get_issue\`; if not already Done, \`save_issue state="Done"\`; if the integration already closed it, leave it). Return merged=true, the PR url, a short detail. If it never merges (checks failing) → merged=false with why. +- anything else ("actionable"/"timeout"/"blocked") → the child is not clean and did NOT enable auto-merge. Return merged=false with a detail; the epic stops here for a human. + +Never force-push the integration branch (in-flight child PRs are based on its tip).`; + }} + + , + ); + } + return {lanes}; + })() + : null} + + {/* ── review: combined-diff, three lenses in parallel (the hard gate before finish) ── */} + {reviewReady ? ( + + + + {() => `Lens 1 — SIMPLIFICATION (/rig-epic review, ${SKILL}). ${cd} Ensure an integration-branch worktree, then diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\` and list merged child PRs (\`gh pr list --base ${integrationBranch} --state merged\`). + +Find abstractions to collapse, helpers one PR added that another PR's final shape made redundant, config knobs nobody sets, code paths the combined diff made dead, one-caller types. Concrete deletions/merges with file:line, highest-impact first. Skip correctness. Return lens="simplify", counts p0p1/p2, and findings.`} + + + {() => `Lens 2 — CROSS-PR CORRECTNESS (/rig-epic review, ${SKILL}). ${cd} Walk the review-pattern catalog (.claude/REVIEWER.md) against the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`. + +Per-PR review already ran; catch interactions only visible at the merged shape (PR-A's helper vs PR-D's stale caller; PR-B removed a knob PR-F still reads). Return lens="crosspr", counts p0p1/p2, and findings with file:line + category.`} + + + {() => `Lens 3 — DEAD CODE & STALE REFS (/rig-epic review, ${SKILL}). ${cd} For the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`: for every symbol added, is it called elsewhere? For every symbol removed, grep the whole tree (workflows, manifests, IaC, scripts, docs) for residual refs. Return lens="deadcode", counts p0p1/p2, and findings with file:line.`} + + + + {() => `Consolidate the three review lenses for the epic on ${integrationBranch} (${SKILL}). Read each lens's output row, dedupe, and produce ONE P0/P1/P2 list grouped by lens with counts. Set clean=true only when there are zero P0/P1. Return p0p1, p2, clean, and the grouped report.`} + + {consolidated && !consolidated.clean ? ( + + ) : null} + {consolidated && !consolidated.clean && reviewApproved ? ( + + {(d) => `Apply the combined-diff review fixes on ${integrationBranch} (/rig-review fix --source local, ${SKILL}). ${cd} Fix the P0/P1 items, keep tests green, commit, and \`git push origin ${integrationBranch}\`. + +Findings: +${d["review-consolidate"].report} + +Return a summary of what you changed.`} + + ) : null} + + ) : null} + + {/* ── full-only gate before the squash-to-trunk PR ── */} + {phase === "full" && reviewReady && finishGatePassed ? ( + + ) : null} + + {/* ── finish: squash the integration branch into one PR to the trunk ── */} + {finishReady ? ( + + {(d) => { + const p = d["epic-preflight"]; + return `You are executing \`finish\` of /rig-epic (${SKILL}). ${cd} Print the intent banner first. The review gate is a HARD precondition and has passed. + +1. \`git fetch origin\`; if the trunk (${p.baseRef}) moved past \`${integrationBranch}\`, rebase the integration branch onto it (\`git rebase ${p.baseRef}\` on a local copy, then \`git push --force-with-lease origin :${integrationBranch}\`). +2. Open the final squash PR to \`${p.defaultBranch}\` **NON-draft** with a title referencing the parent and a body summarizing all children. Include the closes-verb (\`Fixes \` / \`Closes #\`) so the parent auto-closes. Then run \`gh pr ready \` to POST it as ready-for-review (never leave it a draft). +3. Merge behavior: ${merge ? "run `gh pr merge --squash --delete-branch --auto` so CI gates the squash-merge to the trunk; if protectedBranchMergeQueue, use `gh pr merge --auto` with no method flag (the queue decides)." : "STOP at the open, ready PR — squashing to the trunk is the human gate. Do NOT merge."} +4. Parent Done (adaptive — do NOT trust the githubIntegration flag): ${merge ? "you are squash-merging, so once the PR is MERGED, ensure the parent is Done — `get_issue`; if not already Done, `save_issue state=\"Done\"`; the closes-verb also handles it if an integration is live (don't clobber)." : "you are stopping at the open PR, so leave the parent In Progress — the parent moves to Done when a human merges the squash PR (its `Fixes ` closes it if the integration is live; otherwise a follow-up run reconciles it)."} Children were already set Done as they merged. +5. Delete the epic state file \`.rig/epics/${integrationBranch}.json\` once the work is ${merge ? "on the trunk" : "in its final PR"}. + +Return the PR number and url.`; + }} + + ) : null} + + {/* ── final report ── */} + + {() => { + const sq = ctx.outputMaybe(outputs.squashPr, { nodeId: "finish-squash" }); + const st = ctx.outputMaybe(outputs.start, { nodeId: "start" }); + const branch = integrationBranch || pre?.integrationBranch || ""; + let summary: string; + if (doFinish && sq) summary = `Epic ${branch}: squash PR ${sq.url} ${merge ? "squash-merged to trunk" : "open for human merge"}.`; + else if (doReview && consolidated) summary = `Epic ${branch}: combined review ${consolidated.clean ? "clean" : `${consolidated.p0p1} P0/P1`} — ready for finish.`; + else if (doRun && children.length) summary = `Epic ${branch}: ${children.filter(merged).length}/${children.length} children merged into the integration branch.`; + else if (doPlan && st) summary = `Epic started on ${branch}: ${children.length} children planned. Next: rig-epic run (or full).`; + else summary = pre?.banner ?? "rig-epic"; + return { phase, integrationBranch: branch, prUrl: sq?.url ?? "", summary }; + }} + + + ); +}, { output: outputs.epicResult }); diff --git a/smithers/workflows/rig-task.tsx b/smithers/workflows/rig-task.tsx new file mode 100644 index 0000000..d2c869f --- /dev/null +++ b/smithers/workflows/rig-task.tsx @@ -0,0 +1,509 @@ +// smithers-source: seeded +// smithers-metadata-version: 1 +// smithers-display-name: rig-task — implement one unit end-to-end +// smithers-description: Canonicalizes the /rig-task skill as a durable graph — load spec, spec review, TDD (RED -> GREEN -> REFACTOR), pre-PR self-review gate, open PR, then the review-bot loop. Never auto-merges. +// smithers-tags: rig, implement, tdd, review +/** @jsxImportSource smithers-orchestrator */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createSmithers } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../agents"; + +/** + * Role → model, matched to the user's own agent specs in .claude/agents/rig-*.md + * (rig-architect/rig-reviewer = opus, rig-qa/rig-coder = sonnet). Single Claude + * model per role — deliberately NOT the codex/fable-leading pools in agents.ts, + * which are more aggressive than the rig specs. `coord` covers orchestration + * steps (worktree, git/gh, tracker) that have no rig role → conservative sonnet. + */ +const ROLE = { + architect: providers.claudeOpus, // rig-architect → opus + reviewer: providers.claudeOpus, // rig-reviewer → opus + qa: providers.claudeSonnet, // rig-qa → sonnet + coder: providers.claudeSonnet, // rig-coder → sonnet + coord: providers.claudeSonnet, // orchestration/coordination (no rig spec) +} as const; + +/** + * Canonical graph of the /rig-task skill (.claude/skills/rig-task/SKILL.md). + * + * Smithers owns the deterministic control flow + durability; each node is a + * coding agent that executes one step of the skill against the live repo + * (worktree, git, gh, the Linear MCP, `.rig/config.json`). The skill file is + * the per-node spec — prompts point back at it so the two never drift. + * + * preflight -> setup -> [spec-architect | spec-qa] -> (approve-spec?) + * -> RED -> GREEN(loop x3) -> REFACTOR + * -> self-review(loop) -> open-PR (start phase, steps 1-5) + * -> review-bot loop -> result (finish phase, steps 6-7) + */ + +const inputSchema = z.object({ + target: z + .string() + .default("") + .describe('Ticket id (e.g. "CEX-123") OR a quoted ad-hoc "". Empty = infer from the current branch.'), + phase: z + .enum(["start", "finish", "both"]) + .default("both") + .describe("start = steps 1-5 (up to an open PR); finish = steps 6-7 (drive review to clean); both = one continuous run."), + base: z + .string() + .default("") + .describe("Stacked base ref to branch from and target the PR at, instead of vcs.baseRef (e.g. an epic integration branch). Empty = config default."), + local: z + .boolean() + .default(false) + .describe("Force the local /rig-review fix loop in the finish phase even when a cloud auto-fix workflow is enabled."), + autoMerge: z + .boolean() + .default(false) + .describe("Enable auto-merge: after the self-review is clean, squash-merge the PR (`gh pr merge --squash --auto`) so it lands once any required checks pass — or directly if there are none. Children always squash-merge; a configured merge queue wins. Default false → never auto-merges."), + specGate: z + .boolean() + .default(true) + .describe("When false, suppress the pre-coding spec-review APPROVAL gate (spec review still runs, non-blocking). /rig-epic sets this false for children — it front-loads ONE spec approval for the whole epic, and a child that paused mid-run would fail the parent Subflow."), + specNotes: z + .string() + .default("") + .describe("Free-form direction from the caller (e.g. /rig-epic's front-loaded spec gate) resolving spec ambiguities. Folded into the spec the coder works from."), + prNumber: z + .number() + .int() + .optional() + .describe("Open PR number to resume from when phase=finish is run on its own."), +}); + +const outputSchema = z.object({ + outcome: z.string().describe('One of: "pr-open", "clean", "actionable", "timeout", "blocked".'), + unit: z.string(), + prUrl: z.string(), + testsGreen: z.boolean(), + reviewState: z.string(), + summary: z.string(), +}); + +const { Workflow, Sequence, Parallel, Task, Loop, Branch, Approval, UI, smithers, outputs } = createSmithers({ + input: inputSchema, + result: outputSchema, + preflight: z.object({ + unit: z.string(), + isAdHoc: z.boolean(), + baseRef: z.string(), + testCommand: z.string(), + trackerProvider: z.string(), + ticketPrefix: z.string(), + reviewBot: z.string(), + maxRounds: z.number().int(), + defaultBranch: z.string(), + summary: z.string(), + }), + setup: z.object({ + worktreePath: z.string(), + branch: z.string(), + specTitle: z.string(), + specDescription: z.string(), + acceptanceCriteria: z.string(), + trackerState: z.string(), + epicChildMismatch: z.boolean().describe("True if this ticket is an epic child but base is the trunk — block before spending agents."), + suggestedBase: z.string().describe("The integration branch this epic child should stack on (when epicChildMismatch)."), + }), + specArchitect: z.object({ notes: z.string(), blockers: z.array(z.string()) }), + specQa: z.object({ testPlan: z.string(), blockers: z.array(z.string()) }), + specApproval: z.object({ approved: z.boolean() }), + red: z.object({ redVerified: z.boolean(), testOutput: z.string(), summary: z.string() }), + green: z.object({ green: z.boolean(), testOutput: z.string(), summary: z.string() }), + refactor: z.object({ changed: z.boolean(), summary: z.string() }), + reviewFind: z.object({ + p0p1: z.number().int(), + p2: z.number().int(), + p3: z.number().int(), + clean: z.boolean(), + findings: z.string(), + }), + reviewFix: z.object({ summary: z.string() }), + pr: z.object({ number: z.number().int(), url: z.string(), title: z.string() }), + blocked: z.object({ stage: z.string(), reason: z.string(), detail: z.string() }), + reviewBot: z.object({ outcome: z.string(), detail: z.string() }), +}); + +const SKILL = ".claude/skills/rig-task/SKILL.md"; +const cd = (wt: string) => `Work in the worktree \`${wt}\` — start every shell command with \`cd "${wt}" &&\`.`; + +export default smithers((ctx) => { + const { phase } = ctx.input; + const runStart = phase === "start" || phase === "both"; + const runFinish = phase === "finish" || phase === "both"; + + // Gate values, read from prior node outputs (undefined until they run). + const pre = ctx.outputMaybe(outputs.preflight, { nodeId: "preflight" }); + const setup = ctx.outputMaybe(outputs.setup, { nodeId: "setup" }); + const arch = ctx.outputMaybe(outputs.specArchitect, { nodeId: "spec-architect" }); + const qa = ctx.outputMaybe(outputs.specQa, { nodeId: "spec-qa" }); + const specBlockers = [...(arch?.blockers ?? []), ...(qa?.blockers ?? [])]; + // The spec-review approval only gates when specGate is on. /rig-epic passes + // specGate:false (it front-loads one epic-level spec approval) so a child never + // pauses mid-run — a paused Subflow child fails the parent epic. + const specGate = ctx.input.specGate !== false; + const specHasBlockers = specBlockers.length > 0 && specGate; + + const greenRow = ctx.latest(outputs.green, "green-step"); + const green = greenRow?.green === true; + + const reviewRow = ctx.latest(outputs.reviewFind, "review-find"); + const reviewHasP0P1 = (reviewRow?.p0p1 ?? 0) > 0; + const reviewClean = reviewRow?.clean === true; + const maxRounds = pre?.maxRounds ?? 5; + + // Auto-merge mode: after self-review is clean, SQUASH-merge the PR via + // `gh pr merge --squash --auto` (required checks gate it; merges directly if + // there are none). Always squash — never rebase. autoMerge=false → never merges. + const autoMerge = ctx.input.autoMerge === true; + // Early base guard: this ticket is an epic child but base is the trunk. + const specMismatch = setup?.epicChildMismatch === true; + + const openPr = ctx.outputMaybe(outputs.pr, { nodeId: "open-pr" }); + const blockedReview = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-review" }); + const blockedGreen = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-green" }); + const blockedBase = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-base" }); + const startTerminal = !runStart || Boolean(openPr || blockedReview || blockedGreen || blockedBase); + + const prNumber = openPr?.number ?? ctx.input.prNumber; + const canFinish = runFinish && prNumber != null; + const reviewBotRow = ctx.outputMaybe(outputs.reviewBot, { nodeId: "review-bot" }); + const finishTerminal = !canFinish || Boolean(reviewBotRow); + + const showResult = startTerminal && finishTerminal; + + return ( + + + + {/* ── Step 0: resolve the unit + config from .rig/config.json ── */} + + {async () => { + const def = { + baseRef: "origin/main", + testCommand: "npm test", + trackerProvider: "none", + ticketPrefix: "", + reviewBot: "none", + maxRounds: 5, + defaultBranch: "main", + }; + let cfg: any = {}; + try { + cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); + } catch { + /* unconfigured fallback — tracker none, npm test, origin/main */ + } + const ticketPrefix = cfg?.tracker?.ticketPrefix ?? def.ticketPrefix; + const provider = cfg?.tracker?.provider ?? def.trackerProvider; + const target = ctx.input.target.trim(); + const looksLikeTicket = ticketPrefix && new RegExp(`^${ticketPrefix}\\d+$`, "i").test(target); + const isAdHoc = provider === "none" || (target !== "" && !looksLikeTicket); + const baseRef = ctx.input.base.trim() || cfg?.vcs?.baseRef || def.baseRef; + const unit = target || "(infer from branch)"; + return { + unit, + isAdHoc, + baseRef, + testCommand: cfg?.test?.command ?? def.testCommand, + trackerProvider: provider, + ticketPrefix, + reviewBot: cfg?.review?.bot ?? def.reviewBot, + maxRounds: cfg?.review?.maxRounds ?? def.maxRounds, + defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, + summary: `rig-task ${ctx.input.phase}: ${isAdHoc ? `ad-hoc "${unit}"` : unit} → base ${baseRef}, tests \`${cfg?.test?.command ?? def.testCommand}\`, bot ${cfg?.review?.bot ?? def.reviewBot}`, + }; + }} + + + {/* ── START phase: steps 1-5 ── */} + {runStart ? ( + + {/* Step 1 — load spec + set up an isolated worktree */} + + {(d) => { + const p = d["preflight"]; + return `You are executing Step 1 of the /rig-task skill. Read ${SKILL} (Step 1) and \`.rig/config.json\` first, then: + +0. BASE GUARD — do this BEFORE creating any worktree. Determine whether this ticket is an epic CHILD: its tracker parent is an epic, or an integration branch \`-*\` for its parent already exists on origin (\`git ls-remote --heads origin\`). ${ctx.input.base ? `A stacked base (\`${ctx.input.base}\`) was provided, so a child is fine — set epicChildMismatch=false.` : "NO stacked base was provided (base is the trunk)."} If it IS an epic child AND no stacked base was provided, STOP immediately: set epicChildMismatch=true, suggestedBase=, do NOT create a worktree, leave the spec fields empty, and return. Otherwise set epicChildMismatch=false and suggestedBase="". + +1. Load the spec for: ${p.isAdHoc ? `the ad-hoc task "${ctx.input.target}"` : `ticket ${ctx.input.target || "(infer from the current branch)"}`}. + - Tracker "${p.trackerProvider}": fetch the issue (Linear MCP get_issue / \`gh issue view\`) and set it to "In Progress" now (idempotent; do this even under githubIntegration). Ad-hoc: the description IS the spec — if it's a one-liner, expand acceptance criteria. +2. Set up an isolated checkout via the /rig-worktree skill (do NOT inline \`git worktree add\`). Branch from \`${p.baseRef}\`${ctx.input.base ? " (a STACKED base — target the PR at it, not the trunk)" : ""}. Use the tracker's suggested gitBranchName verbatim when present, else vcs.branchConvention. Name the session "FEAT:"/"CHORE:" with --skip-if-prefix "EPIC:". + +CALLER DIRECTION — treat as authoritative spec resolution and **fold it into the specDescription AND acceptanceCriteria you return** (downstream RED/GREEN read those), applying only the parts relevant to THIS unit: +${ctx.input.specNotes?.trim() ? ctx.input.specNotes : "(none)"} + +Restate the acceptance criteria (with the direction folded in) back to yourself before finishing. Return the absolute worktree path, the branch name, the spec title/description, the acceptance criteria (as text), the tracker state you set, epicChildMismatch, and suggestedBase.`; + }} + + + {/* Base guard: epic child on the trunk — stop before spending any review/TDD agents. */} + {setup && specMismatch ? ( + + {(d) => ({ + stage: "base", + reason: "Epic child launched against the trunk — it must stack on the integration branch", + detail: `Re-run with base=${d["setup"].suggestedBase || ""} (or drive it via rig-epic). No worktree created; no review/TDD agents spent.`, + })} + + ) : null} + + {/* Everything past setup runs only when the base is right. */} + {setup && !specMismatch ? ( + + {/* Step 2 — spec review: architect + qa in parallel */} + + + {(d) => { + const s = d["setup"]; + return `You are the ARCHITECT reviewing a spec for implementability (Step 2 of ${SKILL}). ${cd(s.worktreePath)} + +Spec: +${s.specTitle} + +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +Identify ambiguities, missing acceptance criteria, the files that must change, and a suggested implementation order. Return your notes, and a \`blockers\` array listing ONLY things that must be resolved BEFORE coding can start (empty if none).`; + }} + + + {(d) => { + const s = d["setup"]; + return `You are QA reviewing a spec from a testing perspective (Step 2 of ${SKILL}). ${cd(s.worktreePath)} + +Spec: +${s.specTitle} + +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +What test cases are needed? Are the acceptance criteria testable? What edge cases matter? Return a \`testPlan\`, and a \`blockers\` array of ONLY criteria that are untestable/contradictory and must be fixed before coding (empty if none).`; + }} + + + + {/* Spec-review gate: pause for a human only when a blocker was flagged. */} + {specHasBlockers ? ( + + ) : null} + + {/* Step 3 — RED: failing tests first */} + + {(d) => { + const s = d["setup"]; + return `You are executing Step 3 (RED) of ${SKILL}. ${cd(s.worktreePath)} + +Write tests for this unit BEFORE any implementation. Cover every acceptance criterion plus the edge cases the architect flagged. Do NOT stub or comment out — the tests must compile and FAIL for the right reason (missing implementation), not a syntax error. Match the project's test framework and colocation conventions. + +Spec: +${s.specTitle} +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +Architect notes: +${d["spec-architect"].notes} + +QA test plan: +${d["spec-qa"].testPlan} + +Run \`${(ctx.outputMaybe(outputs.preflight, { nodeId: "preflight" })?.testCommand) ?? "the test command"}\` from the worktree. VERIFY RED: each new test must fail with a message reflecting the missing behavior. A new test that passes immediately was pinning existing behavior — rewrite it. Return redVerified=true only when the suite fails for the right reason, plus the test output and a short summary.`; + }} + + + {/* Step 4 — GREEN: minimum implementation, up to 3 iterations */} + + + {(d) => { + const s = d["setup"]; + const prev = ctx.latest(outputs.green, "green-step"); + return `You are executing Step 4 (GREEN) of ${SKILL}. ${cd(s.worktreePath)} + +Make the failing tests pass with the MINIMUM change — no features not required by a test. Explore the affected files first and prefer EXTENDING or REUSING existing code over a parallel implementation. + +Spec: +${s.specTitle} +${s.specDescription} + +Failing tests (from RED): +${d["red-step"].testOutput} +${prev ? `\nPrevious GREEN attempt still failing with:\n${prev.testOutput}\n(fix these remaining failures)` : ""} + +Re-run the full test suite from the worktree. If implementing exposes a missing edge case, add that test first rather than piling untested behavior in. Return green=true only when the whole suite passes, plus the test output and a summary of what you changed.`; + }} + + + + + {/* Step 4.25 — REFACTOR (only while green) */} + + {(d) => { + const s = d["setup"]; + return `You are executing Step 4.25 (REFACTOR) of ${SKILL}. ${cd(s.worktreePath)} + +The implementation is GREEN. If — and only if — there is obvious duplication, awkward naming, or a helper that wants extracting, make that cleanup with NO new behavior, then re-run the full suite to confirm it stays green. If the code is already clean, change nothing. Return changed (true/false) and a one-line summary.`; + }} + + + {/* Step 4.5 — pre-PR self-review gate (find -> fix -> re-find) */} + + + + {(d) => { + const s = d["setup"]; + return `You are executing Step 4.5 (pre-PR self-review) of ${SKILL} — the FIND half. ${cd(s.worktreePath)} + +Run the /rig-review skill for this unit: walk \`${d["preflight"] ? ".claude/REVIEWER.md" : "the review patterns file"}\` against \`git diff ${d["preflight"].baseRef}...HEAD\` and return a triaged P0-P3 list. Count findings by severity: p0p1 (must-fix before merge), p2, p3. Set clean=true only when there are zero P0/P1. Return the findings text too.`; + }} + + + {(d) => { + const s = d["setup"]; + return `You are executing Step 4.5 of ${SKILL} — the FIX half (/rig-review fix, local). ${cd(s.worktreePath)} + +Fix every P0/P1 finding below, keeping the test suite green. Do NOT touch P2/P3 (they ship as follow-ups). + +Findings: +${d["review-find"].findings} + +Return a short summary of what you changed. The loop will re-run the review to confirm convergence.`; + }} +
+ } + else={null} + /> + + + + {/* Step 5 — push + open the PR, only when the self-review is clean */} + + {(d) => { + const s = d["setup"]; + const p = d["preflight"]; + return `You are executing Step 5 of ${SKILL}. ${cd(s.worktreePath)} + +1. Commit all changes with a message referencing the unit. +2. Push the branch. +3. Open a PR with \`gh pr create\` targeting \`${ctx.input.base || p.defaultBranch}\`: + - Title carries the ticket id where a tracker is used, e.g. \`feat(${ctx.input.target || "SCOPE"}): …\`. + - Body: a summary; the tracker link (\`Fixes \` for Linear / \`Closes #\` for GitHub); a test plan; and an \`## Architecture\` section stating any new abstraction/package/dependency/migration or "No architectural change." and WHY existing code wasn't reused. +4. TRACKER LINK + TRANSITION (adaptive — works with OR without a live Linear↔GitHub integration; do NOT trust the githubIntegration config flag, check reality): + - Ensure the PR is linked to the issue: \`get_issue\`; if no attachment already references this PR URL (the integration may have added one), \`create_attachment\` with the PR URL. + - Ensure the issue is In Review: \`get_issue\`; if it is NOT already In Review or further along (Done), \`save_issue state="In Review"\`. If the integration already advanced it, leave it — never clobber a further-along state. +${autoMerge + ? `5. AUTO-MERGE (enabled), self-review is clean: land this PR with **squash** (\`gh pr merge --squash --delete-branch\`, targeting \`${ctx.input.base || p.defaultBranch}\`). If any required check is pending, arm it CI-gated instead: add \`--auto\`. If there are NO required checks (auto-merge can't be armed / the PR is already mergeable), merge directly with the same command minus \`--auto\`. (If \`vcs.protectedBranchMergeQueue\` is true, use \`--auto\` with NO method flag — the queue decides.) Always squash; never rebase. Don't merge manually beyond this.` + : `5. Do NOT \`gh pr merge\` — this run does not auto-merge; the PR waits for a human.`} + +Return the PR number, URL, and title.`; + }} + + } + else={ + + {(d) => ({ + stage: "self-review", + reason: "P0/P1 findings unresolved after the max review rounds", + detail: d["review-find"].findings, + })} + + } + /> + + } + else={ + + {() => { + const g = ctx.latest(outputs.green, "green-step"); + return { + stage: "green", + reason: "Tests still failing after 3 GREEN iterations — spec likely wrong or an unstated constraint", + detail: g?.testOutput ?? "(no test output captured)", + }; + }} + + } + /> + + ) : null} + + ) : null} + + {/* ── FINISH phase: step 6 (review-bot loop) ── */} + {canFinish ? ( + + {(d) => { + const p = d["preflight"]; + const wt = setup?.worktreePath; + return `You are executing Step 6 (review-bot loop) of ${SKILL} for PR #${prNumber}. ${wt ? cd(wt) : "First re-establish context: resolve the open PR's worktree from PR #" + prNumber + " / the current branch and cd into it."} + +Review bot: \`${p.reviewBot}\`. ${ctx.input.local ? "The --local flag is set: force the local /rig-review fix loop." : ""} +- reviewBot "none" → nothing to drive; the Step 4.5 local gate was the whole review. outcome="clean". +- A cloud auto-fix workflow is enabled and --local was NOT passed → WATCH only (do not fix or push). Poll the PR (~60s, up to ~30min) until the bot's review reaches a terminal state; report it. +- Otherwise → DRIVE via the /rig-review fix loop: poll + classify the bot's review, fix via a coding agent, commit, push, re-trigger (\`${p.reviewBot === "bugbot" ? "bugbot run" : "the configured retrigger"}\`), up to ${p.maxRounds} rounds. + +Return outcome as exactly one of: "clean" (no actionable issues — merge gates take over), "actionable" (feedback remains after the last round), or "timeout" (bot didn't respond). Do NOT merge. Include a short detail string.`; + }} + + ) : null} + + {/* ── Step 7 — hand back (report only; never merges) ── */} + {showResult ? ( + + {() => { + const bot = ctx.outputMaybe(outputs.reviewBot, { nodeId: "review-bot" }); + const g = ctx.latest(outputs.green, "green-step"); + const rev = ctx.latest(outputs.reviewFind, "review-find"); + const blk = blockedBase ?? blockedGreen ?? blockedReview; + let outcome = "pr-open"; + if (blk) outcome = "blocked"; + else if (bot) outcome = bot.outcome; + const testsGreen = g?.green === true; + const reviewState = rev ? `${rev.p0p1} P0/P1 + ${rev.p2} P2 + ${rev.p3} P3` : "n/a"; + const unit = pre?.unit ?? ctx.input.target; + const prUrl = openPr?.url ?? ""; + const mergeNote = autoMerge ? "squash-merge enabled (CI-gated if checks exist, else direct)" : "not merged (waits for a human)"; + const summary = blk + ? `BLOCKED at ${blk.stage}: ${blk.reason}.${blk.stage === "base" ? "" : " No PR opened."}` + : bot + ? `PR ${prUrl} — review-bot outcome: ${bot.outcome}. ${bot.outcome === "clean" ? `Merge gate: ${mergeNote}.` : "Left for a human."}` + : `PR opened: ${prUrl}. tests: ${testsGreen ? "green" : "red"}, review: ${reviewState}. ${mergeNote}.`; + return { outcome, unit, prUrl, testsGreen, reviewState, summary }; + }} + + ) : null} + + ); +}, { output: outputs.result }); From 3cb4325ab29c295390d401ff8856a39c0c5bb33f Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:48:10 +0000 Subject: [PATCH 02/18] docs: add 'Pairs with Smithers (optional)' README section Salvaged from the closed PR #9 (magic --with-smithers installer). Keeps the manual, opt-in Smithers story and points at the vendored smithers/ workflow layer (#26); drops the auto-scaffold that broke Rig's lightweight promise. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index d189afe..5bd3a1b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,34 @@ project. Skills read it at runtime — they never hardcode your specifics. See the machine-readable schema, and `rig.config.example.json` for a filled-in reference (the origin project's own values). +## Pairs with Smithers (optional) + +[Smithers](https://smithers.sh) is a **separate, complementary** tool — a +crash-resistant AI-workflow orchestrator (multi-step runs persisted to SQLite, +resumable after a crash, human-approval gates, harness-agnostic agent configs). +The two stack cleanly: + +- **Rig** = the *conventions*: skills, agents, CI, a config profile. Lightweight, + copy-in, runtime-agnostic. +- **Smithers** = the *durable engine*: a CLI + `.smithers/` runtime that *runs* + long, multi-step agent workflows with checkpoint/resume. + +Rig deliberately does **not** bundle or auto-install Smithers (it wants its own +JS-runtime deps — that would break Rig's lightweight, runtime-agnostic promise). +Adoption is a manual, opt-in step for projects that want the durable workflow +layer: + +```bash +bunx smithers-orchestrator init --yes # NB: the package is smithers-orchestrator, not smithers +``` + +Rig ships starter workflows under [`smithers/`](smithers/) (durable `rig-task` +and `rig-epic` runs, plus example agent configs) that you can copy into your +`.smithers/` once it's scaffolded. Set Smithers' `repoCommands.test` to match +your Rig `test.command` so the two share one source of truth. Smithers' own +agent skill and Rig's skills coexist at different layers (task procedures vs. +driving the orchestration CLI). + ## Design principles - **Config over forking.** A parameterizable skill reads `.rig/config.json` From 89ed67b1d4ad9c501db54499a9134af2caa8c4d2 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:58:01 +0000 Subject: [PATCH 03/18] fix(rig-epic): child fan-out deadlock on dangling merge- dependsOn (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a child merged, its lane — including its merge- node — is skipped via `continue`, but the next child still declared dependsOn:[merge-], pointing at an unmounted node → DEPENDENCY_DEADLOCK at every child boundary. The prior `break` guard only deferred the deadlock to the next re-render. Gate the dependency on `prevUnmerged`: depend on the predecessor's merge gate only while it is still in-flight; a merged predecessor means this child is already free to start, so no dependency is emitted. Fixes #27 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- smithers/workflows/rig-epic.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/smithers/workflows/rig-epic.tsx b/smithers/workflows/rig-epic.tsx index 28f168b..6c54867 100644 --- a/smithers/workflows/rig-epic.tsx +++ b/smithers/workflows/rig-epic.tsx @@ -408,9 +408,13 @@ Return JSON: {"proceed": , "direction": " 0 && !merged(children[i - 1])) { - break; // previous child not merged yet — stop the chain here - } + // Serialize each child behind the previous child's merge gate — but + // ONLY while that predecessor is still in-flight. Once it has merged, + // its lane (including its `merge-` node) is skipped above via + // `continue`, so a `dependsOn: ['merge-']` would point at an + // unmounted node → DEPENDENCY_DEADLOCK at every child boundary (#27). + // A merged predecessor means this child is already free to start. + const prevUnmerged = i > 0 && !merged(children[i - 1]); const prevId = i > 0 ? children[i - 1].id : undefined; lanes.push( @@ -420,7 +424,7 @@ Return JSON: {"proceed": , "direction": " {(d) => { From ce27e2f2bc8a45dfa69792210397190108ad39bf Mon Sep 17 00:00:00 2001 From: Paul Gebheim Date: Tue, 28 Jul 2026 01:20:36 -0700 Subject: [PATCH 04/18] feat(smithers): composable TaskFlow/EpicFlow fragments + inline composition + rig-crank (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(smithers): composable TaskFlow/EpicFlow fragments + inline composition + rig-crank Refactor the Smithers workflow layer from monolithic workflows + childRun Subflow fan-out into composable React fragments the parent renders INLINE: - flows/task-flow.tsx (TaskFlow) + flows/epic-flow.tsx (EpicFlow) — the graphs as fragments (no ), taking a `tables` bag + `idPrefix` so they compose inline: one run, native deps, full time-travel, no childRun. Helpers taskSchemas/taskBag, epicSchemas/epicBag (optionally namespaced). - rig-task.tsx / rig-epic.tsx are now thin wrappers over the fragments. - EpicFlow runs each child as an INLINE TaskFlow (was a childRun Subflow). This supersedes the Subflow fan-out — and with it the dangling-`merge-` deadlock that #27/#31 patched, which the inline lanes sidestep structurally (render-gating, no cross-run dependsOn). - The advisor spec gate is now a composed (Fable synthesizer) on the advisor path; same node ids + schema as the human path. - rig-crank.tsx — NEW autonomous build loop: advisor-picks the next ready ticket, routes epic-vs-task to EpicFlow/TaskFlow inline, verifies via an evidence-based risk-probe gate, lands, loops (continueAsNewEvery) until the backlog is dry. - rig-delegation-spike.tsx — a spike evaluating Smithers' DelegationChain suite. - README: document the fragments, the tables-bag/idPrefix seams, rig-crank. Validated graph-clean across all workflows in the trial project. Follow-up: the prompt examples still carry the trial's domain flavor (pre-existing in the pack); a genericization pass can land separately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015ENct54EpFjBkVMecSGh4z * feat(install): --smithers vendors the Smithers workflow pack Opt-in flag copies smithers/{workflows,ui} + agents.example.ts + the pack README into /.smithers/ (no-clobber, agent-agnostic). Never vendors agents.ts (machine-specific — regenerate via `smithers agents add`); the surrounding package comes from `smithers init`. Ticks off the install.sh + agents.ts items in the smithers/README TODO. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015ENct54EpFjBkVMecSGh4z --------- Co-authored-by: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- install.sh | 45 +- smithers/README.md | 41 +- smithers/ui/rig-epic.tsx | 228 ++++++-- smithers/workflows/flows/epic-flow.tsx | 590 ++++++++++++++++++++ smithers/workflows/flows/task-flow.tsx | 498 +++++++++++++++++ smithers/workflows/rig-crank.tsx | 188 +++++++ smithers/workflows/rig-delegation-spike.tsx | 59 ++ smithers/workflows/rig-epic.tsx | 552 +----------------- smithers/workflows/rig-task.tsx | 513 +---------------- 9 files changed, 1632 insertions(+), 1082 deletions(-) create mode 100644 smithers/workflows/flows/epic-flow.tsx create mode 100644 smithers/workflows/flows/task-flow.tsx create mode 100644 smithers/workflows/rig-crank.tsx create mode 100644 smithers/workflows/rig-delegation-spike.tsx diff --git a/install.sh b/install.sh index a3eb9ec..5b362ba 100644 --- a/install.sh +++ b/install.sh @@ -8,8 +8,13 @@ # here — they need per-project parameterization; see ci/README.md, or use the # agent-driven `rig-onboard` skill. # +# The rig **Smithers workflow pack** (smithers/) is opt-in via `--smithers`: it +# vendors smithers/{workflows,ui} + agents.example.ts into /.smithers/. +# The target should have run `smithers init` first (for the surrounding package); +# see smithers/README.md. +# # Usage: -# ./install.sh [--target ] [skill ...] +# ./install.sh [--target ] [--smithers] [skill ...] # # Targets (adapters): # claude-code -> .claude/skills//, .claude/agents/, .claude/scripts/ @@ -28,12 +33,14 @@ set -euo pipefail RIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGETS_CSV="" +INSTALL_SMITHERS=0 POSARGS=() while [[ $# -gt 0 ]]; do case "$1" in --target) TARGETS_CSV+="${TARGETS_CSV:+,}$2"; shift 2 ;; --target=*) TARGETS_CSV+="${TARGETS_CSV:+,}${1#--target=}"; shift ;; - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + --smithers) INSTALL_SMITHERS=1; shift ;; + -h|--help) sed -n '2,34p' "$0"; exit 0 ;; *) POSARGS+=("$1"); shift ;; esac done @@ -182,6 +189,33 @@ install_agents_md() { echo "[agents-md] injected ## Rig index into AGENTS.md (idempotent)" } +# --- Shared: Smithers workflow pack (agent-agnostic, opt-in via --smithers) -- +install_smithers() { + echo "[smithers] rig workflow pack -> .smithers/{workflows,ui}" + if [[ ! -d "$TARGET/.smithers" ]]; then + echo " note: $TARGET/.smithers not found — run 'smithers init' there first for the" + echo " surrounding package (smithers-orchestrator, gateway, smithers.config.ts)." + echo " Copying the rig files anyway so they're in place." + fi + # Workflows: the rig-* wrappers + the composable flows/ fragments they import. + for f in "$RIG_DIR"/smithers/workflows/rig-*.tsx; do + [[ -e "$f" ]] || continue; copy_no_clobber "$f" "$TARGET/.smithers/workflows/$(basename "$f")" + done + for f in "$RIG_DIR"/smithers/workflows/flows/*.tsx; do + [[ -e "$f" ]] || continue; copy_no_clobber "$f" "$TARGET/.smithers/workflows/flows/$(basename "$f")" + done + # UI dashboards. + for f in "$RIG_DIR"/smithers/ui/*.tsx; do + [[ -e "$f" ]] || continue; copy_no_clobber "$f" "$TARGET/.smithers/ui/$(basename "$f")" + done + # Reference only — NEVER vendor agents.ts (machine-specific; regenerate with + # `smithers agents add`). Ship the example + the pack README. + copy_no_clobber "$RIG_DIR/smithers/agents.example.ts" "$TARGET/.smithers/agents.example.ts" + copy_no_clobber "$RIG_DIR/smithers/README.md" "$TARGET/.smithers/RIG-WORKFLOWS.md" + echo " agents.ts is machine-specific — regenerate with 'smithers agents add' (see agents.example.ts)." + echo " re-sync note: this is no-clobber like the rest; to update rig-owned files, delete them first." +} + # --- Run --------------------------------------------------------------------- echo "Installing Rig into: $TARGET" echo "Targets: ${TARGETS[*]}" @@ -200,10 +234,15 @@ done echo "Project profile:" write_profile +if [[ "$INSTALL_SMITHERS" == 1 ]]; then + echo + install_smithers +fi + cat < **Status: incubating.** Tracked by [agent-rig/rig#12](https://github.com/agent-rig/rig/issues/12). -> These files were seeded from a working trial; `install.sh` does **not** vendor -> them yet, and the parity contract below is not yet automated. +> `install.sh --smithers` vendors this pack into `/.smithers/`; the +> parity contract below is not yet automated. ## Contents | File | Role | |---|---| -| `workflows/rig-epic.tsx` | Integration-branch epic: preflight → plan → front-loaded Arch/QA spec → **spec gate** → per-child `rig-task` fan-out (Subflow) → combined-diff review → finish (squash PR). | -| `workflows/rig-task.tsx` | One unit of work: preflight → setup → spec review → RED → GREEN loop → refactor → review-find/fix loop → PR → review-bot. | +| `workflows/flows/task-flow.tsx` | **`TaskFlow`** — the one-unit graph (preflight → setup → spec review → RED → GREEN loop → refactor → review-find/fix loop → PR → review-bot) as a **composable React fragment** (no ``). Takes a `tables` bag + `idPrefix`, so it composes *inline* into a parent — one run, native deps, full time-travel, no childRun. Register with `taskSchemas(ns?)` / build the bag with `taskBag(...)`. | +| `workflows/flows/epic-flow.tsx` | **`EpicFlow`** — the integration-branch epic graph as a fragment: preflight → plan → front-loaded Arch/QA spec (composed ``) → **spec gate** → per-child **inline `TaskFlow`** lanes → combined-diff review → finish (squash PR). `epicSchemas(ns?)` / `epicBag(...)`. | +| `workflows/rig-task.tsx` | Thin `` wrapper over `TaskFlow` (the standalone `/rig-task`). | +| `workflows/rig-epic.tsx` | Thin `` wrapper over `EpicFlow` (the standalone `/rig-epic`). | +| `workflows/rig-crank.tsx` | **Autonomous build loop** (no skill counterpart): advisor-picks the next ready ticket from a scope, classifies epic-vs-task, composes `EpicFlow`/`TaskFlow` **inline**, verifies with an evidence-based **risk-probe gate**, lands, and loops (`continueAsNewEvery` for longevity) until the backlog is dry. | +| `workflows/rig-delegation-spike.tsx` | **Spike / evaluation** — points Smithers' off-the-shelf `DelegationChain` at one ask, to compare the delegation suite against the hand-built rig loop. Reference, not a canonical workflow. | | `ui/rig-epic.tsx`, `ui/rig-task.tsx` | The `` dashboards (`smithers ui `). | | `agents.example.ts` | **Reference only** — a machine-generated `agents.ts`. See "Consuming project" below. | +### Composable fragments (why the split) + +`rig-task`/`rig-epic` used to be monolithic workflows that fanned children out +via childRun ``. That boundary was opaque (the monitor couldn't see into +children) and a paused child could fault the whole parent. The graph is now split +into **fragments** (`flows/*.tsx`) that a parent renders **inline**: `rig-crank` +composes `TaskFlow`/`EpicFlow`, and `EpicFlow` composes a `TaskFlow` per child — +all in **one run**, with native cross-node deps and full time-travel, no childRun. +The seams: a `tables` bag (so a fragment never assumes table *names* in the active +registry — build with `taskBag`/`epicBag`, register with `taskSchemas`/`epicSchemas`, +optionally namespaced), and an `idPrefix` (so multiple inline instances don't +collide — `deps` resolve by physical node id). + ## The advisor gate (autonomous arch gate) Tracked by [agent-rig/rig#13](https://github.com/agent-rig/rig/issues/13). `rig-epic` takes an `advisor` input flag. By default the front-loaded spec gate -(`spec-direction`) is a `` — a human reads the Architect + QA specs, -types free-form direction, and sets `proceed`. With **`advisor: true`** that same -node renders as a Fable (`providers.claude`) `` instead: it reads the specs, -then either +is a hand-rolled Arch/QA gather (``) feeding a `` +(`epic-spec-synthesize`) — a human reads the specs, types free-form direction, +and sets `proceed`. With **`advisor: true`** that whole gather-and-decide is a +composed **``** whose synthesizer is Fable +(`providers.claude`): it reads the Architect + QA specs, then either - `proceed: true` → synthesizes the per-child `direction` and fans out unattended, or - `proceed: false` → the epic **halts with a blocked report** (no human waits). -Same node id + `{ proceed, direction }` schema as the human gate, so everything +Both paths share the same node ids (`epic-spec-gather-{architect,qa}`, +`epic-spec-synthesize`) + `{ proceed, direction }` schema, so everything downstream is unchanged. This lets parallel epics run to child PRs without parking on a human gate. The trunk merge stays human (finish stops at an open PR unless `--merge`). @@ -69,6 +88,6 @@ source of truth; the skill prose mirrors it as guidance. ## TODO (#12) -- [ ] `install.sh`: add a `smithers` target adapter vendoring `smithers/{workflows,ui}` → `/.smithers/{workflows,ui}`. -- [ ] Decide handling of `agents.ts` / `smithers.config.ts` on install (delegate to `smithers init`, or ship a template). +- [x] `install.sh --smithers` vendors `smithers/{workflows,ui}` + `agents.example.ts` → `/.smithers/` (no-clobber). +- [x] `agents.ts` handling: delegated to `smithers init` / `smithers agents add`; the installer ships `agents.example.ts` only (never `agents.ts`). `smithers.config.ts` comes from `smithers init`. - [ ] Automate/verify the parity contract. diff --git a/smithers/ui/rig-epic.tsx b/smithers/ui/rig-epic.tsx index 92a3616..55a4c0c 100644 --- a/smithers/ui/rig-epic.tsx +++ b/smithers/ui/rig-epic.tsx @@ -1,7 +1,12 @@ /** @jsxImportSource react */ import { useState } from "react"; import type { CSSProperties } from "react"; -import { createGatewayReactRoot, useGatewayRuns } from "smithers-orchestrator/gateway-react"; +import { + createGatewayReactRoot, + useGatewayRun, + useGatewayRunEvents, + useGatewayRuns, +} from "smithers-orchestrator/gateway-react"; import { ApprovalPanel, ConnectionBadge, @@ -12,66 +17,191 @@ import { RunList, RunMeta, RunTree, + StatusPill, WorkflowUiShell, } from "smithers-orchestrator/gateway-ui"; const WORKFLOW = "rig-epic"; -/** The canonical rig-epic arc, mirrored from the workflow graph. */ -const STEPS: Array<[string, string]> = [ - ["epic-preflight", "Resolve config + active epic state file"], - ["plan", "Decompose feature → parent + children (blockedBy)"], - ["start", "Cut integration branch · write state file"], - ["approve-run", "Gate — execution is an explicit opt-in"], - ["child-* → merge-*", "Each child via rig-task, stacked, then merge-gated"], - ["review-lenses", "Combined diff: simplify · cross-PR · dead-code"], - ["review-consolidate", "One P0/P1/P2 list; apply-or-pause gate"], - ["approve-finish", "Gate before the squash-to-trunk PR"], - ["finish-squash", "Squash PR to the trunk (merge only with --merge)"], - ["epic-result", "Report — never auto-merges the trunk"], -]; +// A selected node lives in a specific run — the epic OR one of its child sub-runs. +type Sel = { runId: string; nodeId: string }; const border = "1px solid rgba(127,127,127,0.25)"; const cardStyle: CSSProperties = { border, borderRadius: 8, padding: 12 }; +// Subflow children are minted as `run-:child::`. The gateway's +// run-TREE RPC (what uses) rejects those colons at a validateRunId regex, +// so it can't render a child. getRun + the event stream are NOT gated, so we drive +// the child view from those instead (same data `smithers inspect` / the /monitor +// timeline see). +const EV_TO_STATUS: Record = { + NodePending: "queued", + NodeStarted: "running", + NodeRetrying: "running", + NodeFinished: "ok", + NodeFailed: "failed", + NodeCancelled: "cancelled", + NodeSkipped: "ok", + NodeWaitingApproval: "waiting", + NodeWaitingEvent: "waiting", + NodeWaitingTimer: "waiting", +}; + function runIdFromUrl(): string | undefined { if (typeof location === "undefined") return undefined; return new URLSearchParams(location.search).get("runId") ?? undefined; } -function ArcLegend() { +function asRunArray(data: unknown): Array> { + if (Array.isArray(data)) return data as Array>; + const runs = (data as { runs?: unknown } | undefined)?.runs; + return Array.isArray(runs) ? (runs as Array>) : []; +} + +/** `run-123:child:child-CEX-526:0` -> "CEX-526" (or "CEX-526 · retry 1"). */ +function childLabel(runId: string): string { + const m = runId.match(/:child:(.+):(\d+)$/); + if (!m) return runId; + const ticket = m[1].replace(/^child-/, ""); + const iter = Number(m[2]); + return iter > 0 ? `${ticket} · retry ${iter}` : ticket; +} + +/** Fold the child's lifecycle events into a per-node status list (latest wins). */ +function useChildNodes(childId: string): Array<{ id: string; status: string }> { + const { events } = useGatewayRunEvents(childId, { maxEvents: 5000 }); + const byNode = new Map(); + for (const f of events ?? []) { + const p = (f as any).payload as { type?: string; nodeId?: string } | undefined; + const evType = p?.type ?? (f as any).event; + const status = evType ? EV_TO_STATUS[evType] : undefined; + if (!p?.nodeId || !status) continue; + byNode.set(p.nodeId, status); // Map keeps first-seen order; value = latest status + } + return [...byNode.entries()].map(([id, status]) => ({ id, status })); +} + +/** One child rig-task sub-run: live status via getRun, node tree via events. */ +function ChildTask({ + childId, + rowStatus, + sel, + onSelect, +}: { + childId: string; + rowStatus: string; + sel: Sel | undefined; + onSelect: (s: Sel) => void; +}) { + const { data: run } = useGatewayRun(childId); + // The list's row status is stale (queued); the computed runState is authoritative. + const status = + (run?.runState as { state?: string } | undefined)?.state ?? (run?.status as string | undefined) ?? rowStatus; + const nodes = useChildNodes(childId); + const active = sel?.runId === childId; + + return ( +
+ + {childLabel(childId)} + + {childId} + +
+ {nodes.length === 0 ? ( +

Waiting for the first node events…

+ ) : ( +
    + {nodes.map((n) => { + const selected = active && sel?.nodeId === n.id; + return ( +
  • onSelect({ runId: childId, nodeId: n.id })} + style={{ + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 8, + padding: "2px 6px", + borderRadius: 6, + background: selected ? "rgba(127,127,127,0.15)" : "transparent", + }} + > + + {n.id} +
  • + ); + })} +
+ )} +
+
+ ); +} + +function ChildTasks({ + epicRunId, + sel, + onSelect, +}: { + epicRunId: string | undefined; + sel: Sel | undefined; + onSelect: (s: Sel) => void; +}) { + const all = useGatewayRuns({ filter: { limit: 200 } }); + const children = asRunArray(all.data) + .filter((r) => epicRunId && String(r.parentRunId ?? "") === epicRunId) + .sort((a, b) => Number(a.createdAtMs ?? 0) - Number(b.createdAtMs ?? 0)); + return (
-

Arc · plan → run → review → finish

-
    - {STEPS.map(([id, desc]) => ( -
  1. - {id} - — {desc} -
  2. - ))} -
+

+ Child tasks — rig-task sub-runs ({children.length}) + · event-derived (tree RPC can't address child ids) +

+ {children.length === 0 ? ( +

+ None yet — child runs appear here once the advisor proceeds and the epic fans out. +

+ ) : ( +
+ {children.map((c) => ( + + ))} +
+ )}
); } function App() { const [runId, setRunId] = useState(runIdFromUrl()); - const [nodeId, setNodeId] = useState(); + const [sel, setSel] = useState(); const latest = useGatewayRuns({ filter: { workflow: WORKFLOW, limit: 1 } }); - const latestData = latest.data as { runs?: Array<{ runId?: string }> } | Array<{ runId?: string }> | undefined; - const latestRunId = Array.isArray(latestData) ? latestData[0]?.runId : latestData?.runs?.[0]?.runId; + const latestRunId = asRunArray(latest.data)[0]?.runId as string | undefined; const activeRunId = runId ?? latestRunId; + const selectEpicRun = (id: string) => { + setRunId(id); + setSel(undefined); + }; + return ( } actions={
- + Launch rig-epic @@ -87,28 +217,40 @@ function App() {
+ + + {/* Epic tree (top-level id → RunTree is fine) + the event-derived child trees. */}
- { - setRunId(id); - setNodeId(undefined); - }} - /> - +
+

Epic

+ activeRunId && setSel({ runId: activeRunId, nodeId: n.id })} + /> +
+
- setNodeId(node.id)} /> - + {/* Output pane — follows the selected node in whichever run it belongs to. */}
- - +
+ {sel ? ( + + Viewing {sel.nodeId} in{" "} + {sel.runId === activeRunId ? "epic" : childLabel(sel.runId)} + + ) : ( + "Select a node — from the epic or any child task — to see its output." + )} +
+ +
diff --git a/smithers/workflows/flows/epic-flow.tsx b/smithers/workflows/flows/epic-flow.tsx new file mode 100644 index 0000000..fe4e8d6 --- /dev/null +++ b/smithers/workflows/flows/epic-flow.tsx @@ -0,0 +1,590 @@ +/** @jsxImportSource smithers-orchestrator */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { Sequence, Parallel, Task, Branch, Approval, HumanTask, GatherAndSynthesize } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../../agents"; +import { TaskFlow } from "./task-flow"; + +/** + * EpicFlow — the /rig-epic graph as a COMPOSABLE React fragment (no ). + * + * Same contract as TaskFlow: `tables` is a bag mapping each epic table name to the + * composer's OutputTarget (build with `epicBag(outputs, ns?)`, register with + * `epicSchemas(ns?)`); `childTables` is the task bag its inline child TaskFlows write to + * (build with `taskBag(outputs, tables.childRun, "child")`). Composed inline by rig-crank + * or run standalone by the thin rig-epic wrapper — one run, native deps, no childRun. + * + * EpicFlow keeps literal node ids (it is composed one epic at a time; a crank Loop + * iteration-scopes across epics), so it takes no idPrefix — but each child TaskFlow is + * namespaced by `child--`. + */ + +/** + * Role -> model, matched to .claude/agents/rig-*.md (architect/reviewer = opus, + * qa/coder = sonnet). Single Claude model per role, NOT the codex/fable-leading + * pools in agents.ts. `coord` = orchestration steps with no rig role (git/gh, + * state file, merge gate) -> conservative sonnet. + */ +const ROLE = { + architect: providers.claudeOpus, + reviewer: providers.claudeOpus, + qa: providers.claudeSonnet, + coder: providers.claudeSonnet, + coord: providers.claudeSonnet, + // `advisor` = the autonomous arch gate: Fable stands in for the human at the + // front-loaded spec gate so a kicked-off epic runs unattended (see `advisor` input). + advisor: providers.claude, +} as const; + +// Reference the rig-task workflow by file (resolved relative to THIS module, so +// it is cwd-independent). Loaded from the approved root when a child node runs. +// Children run as INLINE TaskFlow fragments (one run, native deps) — no childRun Subflow. + +/** + * Canonical graph of the /rig-epic skill (.claude/skills/rig-epic/SKILL.md). + * + * epic-preflight + * plan -> start (phase plan|full: steps `plan` + `start`) + * (approve-run gate, full only) + * run: for each child in dependency (topological) order — + * inline TaskFlow(--base ) -> merge-gate (phase run|full) + * review: [simplify | cross-pr | dead-code] -> consolidate -> approve (phase review|full) + * finish: review gate -> squash PR -> (optional --merge) (phase finish|full) + * + * Each child is the rig-task workflow run against the integration branch, so + * the two canonicalized skills compose exactly as /rig-epic delegates to + * /rig-task in prose. + */ + +export const childSchema = z.object({ + id: z.string(), + title: z.string(), + blockedBy: z.array(z.string()).default([]), + status: z.string().default("todo"), +}); +type Child = z.infer; + +export const epicInputSchema = z.object({ + phase: z + .enum(["plan", "run", "review", "finish", "full"]) + .default("full") + .describe("full (default) = the whole arc plan→run→review→finish as ONE durable run, pausing only at in-graph approval gates. plan/run/review/finish are partial entry points; finish always runs the review gate first. An existing epic (parent + state, no feature) skips planning and resumes from what's already merged."), + feature: z.string().default("").describe("The feature to decompose (phase plan/full)."), + parent: z.string().default("").describe("Parent id or integration branch to resume from (phase run/review/finish). Empty = infer the single active epic."), + merge: z.boolean().default(false).describe("finish --merge: squash-merge the final PR to the trunk instead of stopping at an open PR."), + advisor: z + .boolean() + .default(false) + .describe("Autonomous arch gate. When true, the front-loaded spec gate (spec-direction) is decided by a Fable advisor instead of a human: it reads the architect+QA specs, then either proceed=true with synthesized per-child direction, or proceed=false → the epic HALTS with a blocked report (no human ever waits). Lets parallel epics run unattended to child PRs."), +}); + +export const epicResultSchema = z.object({ + phase: z.string(), + integrationBranch: z.string(), + prUrl: z.string(), + summary: z.string(), +}); + +// Mirror of rig-task's designated result — the shape each child's inline TaskFlow terminal writes. +const childRunSchema = z.object({ + outcome: z.string(), + unit: z.string(), + prUrl: z.string(), + testsGreen: z.boolean(), + reviewState: z.string(), + summary: z.string(), +}); + +// EpicFlow's own output tables. Registered via `epicSchemas(ns?)`; the child task +// tables are registered separately by the composer via `taskSchemas("child")`. +export const EPIC_TABLES = { + epicPreflight: z.object({ + baseRef: z.string(), + defaultBranch: z.string(), + trackerProvider: z.string(), + integrationBranch: z.string(), + parent: z.string(), + whyEpic: z.string(), + childrenJson: z.string(), + source: z.string(), + banner: z.string(), + }), + plan: z.object({ + parent: z.string(), + parentTitle: z.string(), + integrationBranch: z.string(), + whyEpic: z.string(), + childrenJson: z.string(), + summary: z.string(), + }), + start: z.object({ integrationBranch: z.string(), stateFile: z.string(), summary: z.string() }), + specDirection: z.object({ proceed: z.boolean(), direction: z.string() }), + epicSpec: z.object({ role: z.string(), blockers: z.array(z.string()), notes: z.string() }), + childRun: childRunSchema, + merge: z.object({ childId: z.string(), merged: z.boolean(), prUrl: z.string(), detail: z.string() }), + reviewLens: z.object({ lens: z.string(), p0p1: z.number().int(), p2: z.number().int(), findings: z.string() }), + reviewConsolidated: z.object({ p0p1: z.number().int(), p2: z.number().int(), clean: z.boolean(), report: z.string() }), + reviewApproval: z.object({ approved: z.boolean() }), + reviewFix: z.object({ summary: z.string() }), + finishApproval: z.object({ approved: z.boolean() }), + squashPr: z.object({ number: z.number().int(), url: z.string() }), + epicResult: epicResultSchema, +}; + +const EPIC_KEYS = Object.keys(EPIC_TABLES) as (keyof typeof EPIC_TABLES)[]; +const nsKey = (ns: string, k: string) => (ns ? `${ns}_${k}` : k); + +/** Register EpicFlow's schemas, optionally namespaced (rig-crank namespaces to avoid its task-branch tables). */ +export const epicSchemas = (ns = ""): Record => + Object.fromEntries(Object.entries(EPIC_TABLES).map(([k, v]) => [nsKey(ns, k), v])); + +/** Build the epic `tables` bag from a registry that registered `epicSchemas(ns)`. */ +export const epicBag = (outputs: any, ns = ""): Record => + Object.fromEntries(EPIC_KEYS.map((k) => [k, outputs[nsKey(ns, k)]])); + +const SKILL = ".claude/skills/rig-epic/SKILL.md"; + +/** Kahn topological sort over blockedBy edges; falls back to declaration order on a cycle. */ +function topoOrder(children: Child[]): Child[] { + const byId = new Map(children.map((c) => [c.id, c])); + const indeg = new Map(children.map((c) => [c.id, 0])); + for (const c of children) { + for (const dep of c.blockedBy) { + if (byId.has(dep)) indeg.set(c.id, (indeg.get(c.id) ?? 0) + 1); + } + } + const queue = children.filter((c) => (indeg.get(c.id) ?? 0) === 0); + const ordered: Child[] = []; + const seen = new Set(); + while (queue.length) { + const c = queue.shift()!; + if (seen.has(c.id)) continue; + seen.add(c.id); + ordered.push(c); + for (const other of children) { + if (other.blockedBy.includes(c.id)) { + indeg.set(other.id, (indeg.get(other.id) ?? 0) - 1); + if ((indeg.get(other.id) ?? 0) === 0) queue.push(other); + } + } + } + return ordered.length === children.length ? ordered : children; +} + +function parseChildren(json: string | undefined): Child[] { + if (!json) return []; + try { + const raw = JSON.parse(json); + return Array.isArray(raw) ? raw.map((r) => childSchema.parse({ blockedBy: [], ...r })) : []; + } catch { + return []; + } +} + +type EpicFlowProps = { + input: z.infer; + ctx: any; + tables: Record; // epic tables bag (see epicBag) + childTables: Record; // task bag the inline child TaskFlows write to (taskBag(outputs, tables.childRun, "child")) +}; + +export function EpicFlow({ input, ctx, tables, childTables }: EpicFlowProps) { + const { phase, merge, advisor } = input; + const isFull = phase === "full"; + // `full` is the continuous default: it drives plan→run→review→finish, pausing + // ONLY at in-graph approval gates (no agent stitching phases together). + // Plan only when a feature was given; an existing epic (parent + state) skips + // straight to run/review/finish based on what's already merged. + const doPlan = (phase === "plan" || isFull) && input.feature.trim() !== ""; + const doRun = phase === "run" || isFull; + const doReview = phase === "review" || phase === "finish" || isFull; // review folds INTO finish + const doFinish = phase === "finish" || isFull; + + const pre = ctx.outputMaybe(tables.epicPreflight, { nodeId: "epic-preflight" }); + const planRow = ctx.outputMaybe(tables.plan, { nodeId: "plan" }); + const startRow = ctx.outputMaybe(tables.start, { nodeId: "start" }); + + // The integration branch + children come from the plan (fresh) or the state file (existing epic). + const integrationBranch = planRow?.integrationBranch || pre?.integrationBranch || ""; + const children = topoOrder(parseChildren(planRow?.childrenJson ?? pre?.childrenJson)); + + // A child counts as merged if the state file already says so, or its run-loop merge node recorded it. + const merged = (c: Child) => c.status === "merged" || ctx.outputMaybe(tables.merge, { nodeId: `merge-${c.id}` })?.merged === true; + const allMerged = children.length > 0 && children.every(merged); + const startedFresh = doPlan; // a fresh plan cuts the branch first + + // FRONT-LOADED SPEC REVIEW: review ALL children's specs up front (architect + qa), + // gate on ONE approval, then run children with their own spec gate OFF. (Children are + // now inline TaskFlows in this one run, so a pause no longer FAILS the epic the way a + // childRun Subflow did — but front-loading still avoids stalling the drain on a gate.) + // Node ids follow GatherAndSynthesize's convention (`${id}-gather-${source}` and + // `${id}-synthesize`) so the advisor path (a composed GatherAndSynthesize) and the + // human path (hand-rolled, same ids) share one set of derived flags below. + const specArch = ctx.outputMaybe(tables.epicSpec, { nodeId: "epic-spec-gather-architect" }); + const specQa = ctx.outputMaybe(tables.epicSpec, { nodeId: "epic-spec-gather-qa" }); + const specReviewed = Boolean(specArch && specQa); + const specBlockers = [...(specArch?.blockers ?? []), ...(specQa?.blockers ?? [])]; + const specHasBlockers = specBlockers.length > 0; + // The spec gate produces free-form direction (not just approve/deny): a human + // (HumanTask) by default, or a Fable advisor (Task) when `advisor` is set for + // unattended runs. Either way `direction` is threaded into every child's coder; + // `proceed` gates execution (false = halt with a blocked report). + const specDir = ctx.outputMaybe(tables.specDirection, { nodeId: "epic-spec-synthesize" }); + const specAnswered = Boolean(specDir); + const runApproved = specDir?.proceed === true; + const direction = specDir?.direction ?? ""; + // Spec review can run once the branch + children are known (start done for a fresh epic). + const specReviewReady = doRun && integrationBranch !== "" && children.length > 0 && !allMerged && (!startedFresh || Boolean(startRow)); + // Children execute only after the human answers the spec gate with proceed=true. + const readyToRun = specReviewReady && specReviewed && runApproved; + + const consolidated = ctx.outputMaybe(tables.reviewConsolidated, { nodeId: "review-consolidate" }); + const reviewClean = consolidated?.clean === true; + const reviewApproved = ctx.outputMaybe(tables.reviewApproval, { nodeId: "approve-review" })?.approved === true; + // Review runs once all children are in (or immediately for an already-merged epic). + const reviewReady = doReview && integrationBranch !== "" && allMerged; + + const finishGatePassed = reviewClean || reviewApproved; // review is a HARD gate before the squash PR + const finishApproved = isFull ? ctx.outputMaybe(tables.finishApproval, { nodeId: "approve-finish" })?.approved === true : true; + const finishReady = doFinish && finishGatePassed && (!isFull || finishApproved); + + const cd = integrationBranch ? `The integration branch is \`${integrationBranch}\`.` : ""; + + return ( + <> + {/* Resolve config + (for run/review/finish) the active epic state file. */} + + {async () => { + const def = { baseRef: "origin/main", defaultBranch: "main", trackerProvider: "none" }; + let cfg: any = {}; + try { + cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); + } catch { + /* unconfigured fallback */ + } + // For run/review/finish, read the epic state file. Search THIS worktree and + // (fallback) the main checkout, since /rig-epic may have been run elsewhere. + let integrationBranch = ""; + let parent = input.parent; + let whyEpic = ""; + let childrenJson = "[]"; + let source = "none"; + const epicDirs = [resolve(process.cwd(), ".rig/epics")]; + try { + const { execSync } = await import("node:child_process"); + // The main worktree's .git parent → its .rig/epics. + const commonGit = execSync("git rev-parse --path-format=absolute --git-common-dir", { encoding: "utf8" }).trim(); + const mainRepo = resolve(commonGit, ".."); + const mainEpics = resolve(mainRepo, ".rig/epics"); + if (!epicDirs.includes(mainEpics)) epicDirs.push(mainEpics); + } catch { + /* not a git repo / git missing — cwd dir only */ + } + try { + const { readdirSync, existsSync } = await import("node:fs"); + for (const dir of epicDirs) { + if (!existsSync(dir)) continue; + const files = (readdirSync(dir) as string[]).filter((f) => f.endsWith(".json")); + // A NAMED parent only matches a state file that references it (case-insensitive: + // parent may be the ticket id `ABC-42` or the branch slug `abc-42-…`). Never + // fall back to "the single existing file" when a parent is named — that would + // hijack a DIFFERENT epic's state (e.g. two epics running in parallel). + const needle = input.parent.trim().toLowerCase(); + const pick = needle + ? files.find((f) => f.toLowerCase().includes(needle)) + : files.length === 1 + ? files[0] + : undefined; + if (pick) { + const state = JSON.parse(readFileSync(resolve(dir, pick), "utf8")); + integrationBranch = state.integrationBranch ?? pick.replace(/\.json$/, ""); + parent = state.parent ?? parent; + whyEpic = state.whyEpic ?? ""; + childrenJson = JSON.stringify(state.children ?? []); + source = "state-file"; + break; + } + } + } catch { + /* no state file yet — plan will create one */ + } + // Last resort: no state file, but the caller named the integration branch via `parent`. + if (integrationBranch === "" && input.parent.trim() !== "") { + integrationBranch = input.parent.trim(); + source = "input-parent"; + } + const banner = `rig-epic: ${input.phase} — ${integrationBranch ? `epic ${integrationBranch}` : input.feature || "(new epic)"}. PRs target ${integrationBranch || "the integration branch"}, not the trunk. Will not auto-merge the trunk without --merge.`; + return { + baseRef: cfg?.vcs?.baseRef ?? def.baseRef, + defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, + trackerProvider: cfg?.tracker?.provider ?? def.trackerProvider, + integrationBranch, + parent, + whyEpic, + childrenJson, + source, + banner, + }; + }} + + + {/* ── plan + start ── */} + {doPlan ? ( + + + {(d) => { + const p = d["epic-preflight"]; + const namedParent = (input.parent || p.parent || "").trim(); + return `You are executing the \`plan\` step of the /rig-epic skill. Read ${SKILL} and \`.rig/config.json\` first. + +**FIRST decide adopt vs decompose:** +${namedParent ? `A parent is named: \`${namedParent}\`. Fetch it from the tracker (${p.trackerProvider}). If it ALREADY EXISTS and has child issues, **ADOPT — do NOT decompose or create anything**: list its children (Linear: \`list_issues parentId=${namedParent}\`), and return them verbatim as \`childrenJson\` = JSON array of {id, title, blockedBy:[...], status}. Derive \`blockedBy\` from the children's tracker relations, or (if none are set) from the stack order in the parent's description (each later child blocked by the first/foundational one). Set parentTitle from the parent, whyEpic from its description, and propose the integration branch name \`-\` (kebab). Skip steps 1-4 below. If the named parent does NOT exist yet, fall through to decompose.` : `No parent named — decompose the feature below into a new epic.`} + +Otherwise, decompose this feature into a parent + 3-8 children: +"${input.feature}" + +1. Read product/spec docs and explore the codebase (sourceScope) to see what exists; in a tracker, search for near-duplicate items first. +2. SANITY-CHECK it is genuinely epic-shaped — at least one child's runtime contract depends on another being only partially complete (the interleave test). If the items are independent, STOP and say it should be a /rig-sprint instead (return an empty children list and explain in whyEpic). +3. Create the parent (tracker: ${p.trackerProvider}) and each child with concrete, testable acceptance criteria, small enough for one agent session (1-3 files), foundational work first. +4. Record \`blockedBy\` for EVERY real dependency — this drives execution order. +5. Propose the integration branch name \`-\` (kebab). + +Return: parent (id or slug), parentTitle, integrationBranch, whyEpic, and childrenJson = a JSON array string of {id, title, blockedBy:[...], status}.`; + }} + + + {(d) => { + const pl = d["plan"]; + const p = d["epic-preflight"]; + return `You are executing the \`start\` step of the /rig-epic skill (${SKILL}). Print the intent banner first. + +1. \`git fetch origin\`; confirm the parent and >=1 child exist. +2. Cut the integration branch \`${pl.integrationBranch}\` from \`${p.baseRef}\` WITHOUT a local checkout, and non-destructively (leave it if it already exists): + git push origin ${p.baseRef}:refs/heads/${pl.integrationBranch} +3. Write \`.rig/epics/${pl.integrationBranch}.json\` with { parent, parentTitle, integrationBranch, whyEpic, children:[{id,title,blockedBy,branch:null,status:"todo"}] } using the plan's data: + parent=${pl.parent}; whyEpic=${JSON.stringify(pl.whyEpic)}; children=${pl.childrenJson} + Ensure \`.rig/epics/\` is in .gitignore. +4. In a tracker, add an "Integration branch: target \`${pl.integrationBranch}\`, not the trunk" note to each child, and ensure the PARENT is In Progress (adaptive: \`get_issue\`; if not already started, \`save_issue state="In Progress"\`). Children get their own In Progress from their rig-task Step 1. +5. Name the session "EPIC: ${pl.parentTitle} (${pl.parent})". + +Return integrationBranch, the stateFile path, and a summary. Do NOT start executing children — that is an explicit opt-in.`; + }} + + + ) : null} + + {/* ── front-loaded spec review of ALL children, then ONE decision ── + The advisor path is a composed (a tested Smithers + primitive): a Parallel gather of the architect + qa specs, then a synthesis + Task (the advisor) returning {proceed, direction}. Its synthesis `needs` both + gathers, so it self-gates — we no longer hand-roll the specReviewed/specAnswered + guard for this path. The human path keeps the SAME node ids + (epic-spec-gather-{architect,qa}, epic-spec-synthesize) so the derived flags + above stay path-agnostic. */} + {specReviewReady ? ( + advisor ? ( + c.id).join(", ")}) and review each for implementability AGAINST the integration branch (inspect it: \`git fetch origin\`; the terminal service + prior children live on \`${integrationBranch}\`). Flag ambiguities, missing acceptance criteria, and cross-child ordering issues. Return role="architect", notes (per child), and a \`blockers\` array of ONLY things that must be resolved before ANY coding starts — prefix each with the child id (e.g. "CEX-542: gap semantics undefined vs the shared sequencer …").`, + }, + qa: { + agent: ROLE.reviewer, + prompt: `Front-loaded QA spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec (${children.map((c) => c.id).join(", ")}) and review each from a testing perspective against the integration branch. Return role="qa", notes, and a \`blockers\` array of ONLY untestable/contradictory criteria that must be fixed before coding — prefix each with the child id.`, + }, + }} + synthesizer={ROLE.advisor} + gatherOutput={tables.epicSpec} + synthesisOutput={tables.specDirection} + synthesisPrompt={`You are the ARCH ADVISOR for the epic on \`${integrationBranch}\` (${SKILL}). ${cd} You stand in for the human at the front-loaded spec gate: BEFORE ${children.length} parallel implementation runs (${children.map((c) => c.id).join(", ")}) commit against the integration branch, you decide whether to proceed and produce the steering direction every child's coder will follow. No human is waiting — you ARE the gate. + +Two independent front-loaded reviews just ran against \`${integrationBranch}\`: +- ARCHITECT — notes: ${JSON.stringify(specArch?.notes ?? "")} +- QA — notes: ${JSON.stringify(specQa?.notes ?? "")} +${ + specHasBlockers + ? `Flagged blocker(s) (${specBlockers.length}):\n- ${specBlockers.join("\n- ")}` + : "Neither reviewer flagged a blocker." +} + +Judge ADVERSARIALLY — a paused child fails the whole epic, and fanning ${children.length} runs out on a bad spec wastes real compute: +- Verify against the branch if useful (\`git fetch origin\`; the terminal service + any prior children live on \`${integrationBranch}\`). +- proceed=true ONLY if the specs are coherent, each child is well-scoped, and there is NO contradiction or missing decision that would make the parallel tasks diverge or need rework. Then set \`direction\` to concrete per-child guidance (prefix each with the child id, e.g. "CEX-543: funding line = rate+countdown") that resolves every flagged item — this is handed verbatim to each child's coder. +- proceed=false if there is a genuine blocker that must be resolved before ANY coding. Put the specific blocking reason(s) and what a human must decide into \`direction\`; the epic HALTS with that as its blocked report. + +Return JSON: {"proceed": , "direction": ""}`} + /> + ) : ( + + + + {() => `Front-loaded ARCHITECT spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec from the tracker (${children.map((c) => c.id).join(", ")}) and review each for implementability AGAINST the integration branch (inspect it: \`git fetch origin\`; the terminal service + prior children live on \`${integrationBranch}\`). Flag ambiguities, missing acceptance criteria, and cross-child ordering issues. Return role="architect", notes (per child), and a \`blockers\` array of ONLY things that must be resolved before ANY coding starts — prefix each with the child id (e.g. "CEX-542: gap semantics undefined vs the shared sequencer …").`} + + + {() => `Front-loaded QA spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec (${children.map((c) => c.id).join(", ")}) and review each from a testing perspective against the integration branch. Return role="qa", notes, and a \`blockers\` array of ONLY untestable/contradictory criteria that must be fixed before coding — prefix each with the child id.`} + + + {specReviewed && !specAnswered ? ( + c.id).join(", ")}) on \`${integrationBranch}\`.\n\n${ + specHasBlockers + ? `Found ${specBlockers.length} item(s) needing your direction:\n\n- ${specBlockers.join("\n- ")}\n\n` + : "No blockers found.\n\n" + }Type direction that will be handed to EVERY child's coder (address the items above — e.g. "CEX-543: funding line = rate+countdown", "CEX-546: risk panel against RiskFrameSchema fixtures"). Then set proceed=true to execute all children uninterrupted, or proceed=false to halt.\n\nAnswer with JSON, e.g.:\n{"proceed": true, "direction": ""}`} + /> + ) : null} + + ) + ) : null} + + {/* ── run: each child = rig-task against the integration branch, then a merge gate ── */} + {readyToRun + ? (() => { + const lanes: React.ReactElement[] = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (merged(child)) { + continue; // already merged (state file or a prior lane) — its lane is done + } + if (i > 0 && !merged(children[i - 1])) { + break; // previous child not merged yet — stop the chain here + } + // Depend on the previous child's merge gate ONLY while that child is + // still in-flight. Once it's merged its lane is skipped (the `continue` + // above), so a dependsOn on its now-unrendered `merge-` node would + // dangle and deadlock the fan-out (DEPENDENCY_DEADLOCK) — which is + // a hazard when a prior child's merge node is skipped after it lands. + const prevUnmerged = i > 0 && !merged(children[i - 1]); + // Only one unmerged child lane renders at a time (the loop `break`s while + // the previous child is in-flight), so the child TaskFlows are serialized by + // render-gating — no dependsOn needed on a fragment. Each child's nodes are + // namespaced by idPrefix `child--`; its terminal lands in tables.childRun. + void prevUnmerged; + lanes.push( + + + + {(d) => { + const run = d[`child-${child.id}-result`]; + return `You are the merge gate for epic child ${child.id} (${SKILL}). ${cd} + +The child's rig-task run finished with outcome "${run.outcome}" (PR ${run.prUrl}) and, if clean, squash-merged it into the integration branch (or armed \`--squash --auto\` if a required check was pending). + +ONLY "clean" is merge-green: +- "clean" → the child PR **squash-merges** into \`${integrationBranch}\` (directly when there are no required checks, else once they pass). Confirm/WAIT: poll \`gh pr view --json state\` (~60s intervals, up to ~30min) until state=MERGED, then \`git fetch origin\` and confirm the integration tip advanced. Then: (a) update \`.rig/epics/${integrationBranch}.json\` — mark ${child.id} status="merged" + record its branch/PR; (b) ensure the child ticket ${child.id} is Done (adaptive — ignore the githubIntegration config flag: \`get_issue\`; if not already Done, \`save_issue state="Done"\`; if the integration already closed it, leave it). Return merged=true, the PR url, a short detail. If it never merges (checks failing) → merged=false with why. +- anything else ("actionable"/"timeout"/"blocked") → the child is not clean and did NOT enable auto-merge. Return merged=false with a detail; the epic stops here for a human. + +Never force-push the integration branch (in-flight child PRs are based on its tip).`; + }} + + , + ); + } + return {lanes}; + })() + : null} + + {/* ── review: combined-diff, three lenses in parallel (the hard gate before finish) ── */} + {reviewReady ? ( + + + + {() => `Lens 1 — SIMPLIFICATION (/rig-epic review, ${SKILL}). ${cd} Ensure an integration-branch worktree, then diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\` and list merged child PRs (\`gh pr list --base ${integrationBranch} --state merged\`). + +Find abstractions to collapse, helpers one PR added that another PR's final shape made redundant, config knobs nobody sets, code paths the combined diff made dead, one-caller types. Concrete deletions/merges with file:line, highest-impact first. Skip correctness. Return lens="simplify", counts p0p1/p2, and findings.`} + + + {() => `Lens 2 — CROSS-PR CORRECTNESS (/rig-epic review, ${SKILL}). ${cd} Walk the review-pattern catalog (.claude/REVIEWER.md) against the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`. + +Per-PR review already ran; catch interactions only visible at the merged shape (PR-A's helper vs PR-D's stale caller; PR-B removed a knob PR-F still reads). Return lens="crosspr", counts p0p1/p2, and findings with file:line + category.`} + + + {() => `Lens 3 — DEAD CODE & STALE REFS (/rig-epic review, ${SKILL}). ${cd} For the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`: for every symbol added, is it called elsewhere? For every symbol removed, grep the whole tree (workflows, manifests, IaC, scripts, docs) for residual refs. Return lens="deadcode", counts p0p1/p2, and findings with file:line.`} + + + + {() => `Consolidate the three review lenses for the epic on ${integrationBranch} (${SKILL}). Read each lens's output row, dedupe, and produce ONE P0/P1/P2 list grouped by lens with counts. Set clean=true only when there are zero P0/P1. Return p0p1, p2, clean, and the grouped report.`} + + {consolidated && !consolidated.clean ? ( + + ) : null} + {consolidated && !consolidated.clean && reviewApproved ? ( + + {(d) => `Apply the combined-diff review fixes on ${integrationBranch} (/rig-review fix --source local, ${SKILL}). ${cd} Fix the P0/P1 items, keep tests green, commit, and \`git push origin ${integrationBranch}\`. + +Findings: +${d["review-consolidate"].report} + +Return a summary of what you changed.`} + + ) : null} + + ) : null} + + {/* ── full-only gate before the squash-to-trunk PR ── */} + {phase === "full" && reviewReady && finishGatePassed ? ( + + ) : null} + + {/* ── finish: squash the integration branch into one PR to the trunk ── */} + {finishReady ? ( + + {(d) => { + const p = d["epic-preflight"]; + return `You are executing \`finish\` of /rig-epic (${SKILL}). ${cd} Print the intent banner first. The review gate is a HARD precondition and has passed. + +1. \`git fetch origin\`; if the trunk (${p.baseRef}) moved past \`${integrationBranch}\`, rebase the integration branch onto it (\`git rebase ${p.baseRef}\` on a local copy, then \`git push --force-with-lease origin :${integrationBranch}\`). +2. Open the final squash PR to \`${p.defaultBranch}\` **NON-draft** with a title referencing the parent and a body summarizing all children. Include the closes-verb (\`Fixes \` / \`Closes #\`) so the parent auto-closes. Then run \`gh pr ready \` to POST it as ready-for-review (never leave it a draft). +3. Merge behavior: ${merge ? "run `gh pr merge --squash --delete-branch --auto` so CI gates the squash-merge to the trunk; if protectedBranchMergeQueue, use `gh pr merge --auto` with no method flag (the queue decides)." : "STOP at the open, ready PR — squashing to the trunk is the human gate. Do NOT merge."} +4. Parent Done (adaptive — do NOT trust the githubIntegration flag): ${merge ? "you are squash-merging, so once the PR is MERGED, ensure the parent is Done — `get_issue`; if not already Done, `save_issue state=\"Done\"`; the closes-verb also handles it if an integration is live (don't clobber)." : "you are stopping at the open PR, so leave the parent In Progress — the parent moves to Done when a human merges the squash PR (its `Fixes ` closes it if the integration is live; otherwise a follow-up run reconciles it)."} Children were already set Done as they merged. +5. Delete the epic state file \`.rig/epics/${integrationBranch}.json\` once the work is ${merge ? "on the trunk" : "in its final PR"}. + +Return the PR number and url.`; + }} + + ) : null} + + {/* ── final report ── */} + + {() => { + const sq = ctx.outputMaybe(tables.squashPr, { nodeId: "finish-squash" }); + const st = ctx.outputMaybe(tables.start, { nodeId: "start" }); + const branch = integrationBranch || pre?.integrationBranch || ""; + let summary: string; + if (doFinish && sq) summary = `Epic ${branch}: squash PR ${sq.url} ${merge ? "squash-merged to trunk" : "open for human merge"}.`; + else if (doReview && consolidated) summary = `Epic ${branch}: combined review ${consolidated.clean ? "clean" : `${consolidated.p0p1} P0/P1`} — ready for finish.`; + else if (doRun && children.length) summary = `Epic ${branch}: ${children.filter(merged).length}/${children.length} children merged into the integration branch.`; + else if (doPlan && st) summary = `Epic started on ${branch}: ${children.length} children planned. Next: rig-epic run (or full).`; + else summary = pre?.banner ?? "rig-epic"; + return { phase, integrationBranch: branch, prUrl: sq?.url ?? "", summary }; + }} + + + ); +} diff --git a/smithers/workflows/flows/task-flow.tsx b/smithers/workflows/flows/task-flow.tsx new file mode 100644 index 0000000..107d53e --- /dev/null +++ b/smithers/workflows/flows/task-flow.tsx @@ -0,0 +1,498 @@ +/** @jsxImportSource smithers-orchestrator */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { Sequence, Parallel, Task, Loop, Branch, Approval } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../../agents"; + +/** + * TaskFlow — the /rig-task graph as a COMPOSABLE React fragment (no ). + * + * The same nodes as the standalone rig-task workflow, but authored as a function + * component so a parent (rig-crank, EpicFlow) can render it INLINE — one graph, one + * run, native deps, full time-travel — instead of a childRun . + * + * Composition contract: + * - `tables` is a BAG mapping each logical table name (preflight, setup, …, result) + * to the OutputTarget the composer registered. TaskFlow never assumes table NAMES + * in the active registry, so it composes into a registry with colliding names (e.g. + * rig-epic's own `reviewFix`/`blocked`) by mapping the bag to namespaced tables. + * Build it with `taskBag(outputs, resultTable, ns?)`; register schemas with `taskSchemas(ns?)`. + * - `idPrefix` namespaces every node id AND dep key (deps resolve by node id), so + * multiple inline instances (EpicFlow's children) never collide. Default "". + * - the terminal summary lands in `tables.result` — the composer's choice of target. + * - reads (`ctx.outputMaybe`/`ctx.latest`) all use the prefixed node ids. + */ + +// Role → model, matched to the user's rig agent specs (architect/reviewer=opus, qa/coder=sonnet). +export const ROLE = { + architect: providers.claudeOpus, + reviewer: providers.claudeOpus, + qa: providers.claudeSonnet, + coder: providers.claudeSonnet, + coord: providers.claudeSonnet, +} as const; + +export const taskInputSchema = z.object({ + target: z.string().default("").describe('Ticket id (e.g. "CEX-123") OR a quoted ad-hoc "". Empty = infer from the current branch.'), + phase: z.enum(["start", "finish", "both"]).default("both").describe("start = steps 1-5 (up to an open PR); finish = steps 6-7 (drive review to clean); both = one continuous run."), + base: z.string().default("").describe("Stacked base ref to branch from and target the PR at, instead of vcs.baseRef. Empty = config default."), + local: z.boolean().default(false).describe("Force the local /rig-review fix loop in the finish phase even when a cloud auto-fix workflow is enabled."), + autoMerge: z.boolean().default(false).describe("Enable auto-merge: after the self-review is clean, squash-merge the PR so it lands once required checks pass — or directly if there are none. Always squash. Default false → never auto-merges."), + specGate: z.boolean().default(true).describe("When false, suppress the pre-coding spec-review APPROVAL gate (spec review still runs, non-blocking). /rig-epic sets this false for children."), + specNotes: z.string().default("").describe("Free-form direction from the caller resolving spec ambiguities. Folded into the spec the coder works from."), + prNumber: z.number().int().optional().describe("Open PR number to resume from when phase=finish is run on its own."), +}); + +export const taskResultSchema = z.object({ + outcome: z.string().describe('One of: "pr-open", "clean", "actionable", "timeout", "blocked".'), + unit: z.string(), + prUrl: z.string(), + testsGreen: z.boolean(), + reviewState: z.string(), + summary: z.string(), +}); + +// The output tables TaskFlow writes (excluding the terminal summary, which the +// composer supplies as `tables.result`). Registered via `taskSchemas(ns?)`. +export const TASK_TABLES = { + preflight: z.object({ + unit: z.string(), + isAdHoc: z.boolean(), + baseRef: z.string(), + testCommand: z.string(), + trackerProvider: z.string(), + ticketPrefix: z.string(), + reviewBot: z.string(), + maxRounds: z.number().int(), + defaultBranch: z.string(), + summary: z.string(), + }), + setup: z.object({ + worktreePath: z.string(), + branch: z.string(), + specTitle: z.string(), + specDescription: z.string(), + acceptanceCriteria: z.string(), + trackerState: z.string(), + epicChildMismatch: z.boolean(), + suggestedBase: z.string(), + }), + specArchitect: z.object({ notes: z.string(), blockers: z.array(z.string()) }), + specQa: z.object({ testPlan: z.string(), blockers: z.array(z.string()) }), + specApproval: z.object({ approved: z.boolean() }), + red: z.object({ redVerified: z.boolean(), testOutput: z.string(), summary: z.string() }), + green: z.object({ green: z.boolean(), testOutput: z.string(), summary: z.string() }), + refactor: z.object({ changed: z.boolean(), summary: z.string() }), + reviewFind: z.object({ p0p1: z.number().int(), p2: z.number().int(), p3: z.number().int(), clean: z.boolean(), findings: z.string() }), + reviewFix: z.object({ summary: z.string() }), + pr: z.object({ number: z.number().int(), url: z.string(), title: z.string() }), + blocked: z.object({ stage: z.string(), reason: z.string(), detail: z.string() }), + reviewBot: z.object({ outcome: z.string(), detail: z.string() }), +}; + +const SKILL = ".claude/skills/rig-task/SKILL.md"; +const cd = (wt: string) => `Work in the worktree \`${wt}\` — start every shell command with \`cd "${wt}" &&\`.`; + +const TASK_KEYS = Object.keys(TASK_TABLES) as (keyof typeof TASK_TABLES)[]; +const nsKey = (ns: string, k: string) => (ns ? `${ns}_${k}` : k); + +/** + * Register TaskFlow's schemas in a createSmithers call, optionally namespaced. + * createSmithers({ ...taskSchemas() }) // canonical keys (rig-task, rig-crank) + * createSmithers({ ...taskSchemas("child") }) // child_preflight, … (rig-epic — no collision) + */ +export const taskSchemas = (ns = ""): Record => + Object.fromEntries(Object.entries(TASK_TABLES).map(([k, v]) => [nsKey(ns, k), v])); + +/** + * Build the `tables` bag from a registry that registered `taskSchemas(ns)`. + * `resultTable` is where the terminal summary lands (outputs.result | outputs.taskUnit | outputs.childRun). + */ +export const taskBag = (outputs: any, resultTable: any, ns = ""): Record => ({ + ...Object.fromEntries(TASK_KEYS.map((k) => [k, outputs[nsKey(ns, k)]])), + result: resultTable, +}); + +type TaskFlowProps = { + input: z.infer; + ctx: any; + tables: Record; // bag: preflight, setup, …, result → OutputTargets (see taskBag) + idPrefix?: string; // namespaces node ids + dep keys for inline multi-instance use +}; + +export function TaskFlow({ input, ctx, tables, idPrefix = "" }: TaskFlowProps) { + const nid = (n: string) => `${idPrefix}${n}`; + const { phase } = input; + const runStart = phase === "start" || phase === "both"; + const runFinish = phase === "finish" || phase === "both"; + + const pre = ctx.outputMaybe(tables.preflight, { nodeId: nid("preflight") }); + const setup = ctx.outputMaybe(tables.setup, { nodeId: nid("setup") }); + const arch = ctx.outputMaybe(tables.specArchitect, { nodeId: nid("spec-architect") }); + const qa = ctx.outputMaybe(tables.specQa, { nodeId: nid("spec-qa") }); + const specBlockers = [...(arch?.blockers ?? []), ...(qa?.blockers ?? [])]; + const specGate = input.specGate !== false; + const specHasBlockers = specBlockers.length > 0 && specGate; + + const greenRow = ctx.latest(tables.green, nid("green-step")); + const green = greenRow?.green === true; + + const reviewRow = ctx.latest(tables.reviewFind, nid("review-find")); + const reviewHasP0P1 = (reviewRow?.p0p1 ?? 0) > 0; + const reviewClean = reviewRow?.clean === true; + const maxRounds = pre?.maxRounds ?? 5; + + const autoMerge = input.autoMerge === true; + const specMismatch = setup?.epicChildMismatch === true; + + const openPr = ctx.outputMaybe(tables.pr, { nodeId: nid("open-pr") }); + const blockedReview = ctx.outputMaybe(tables.blocked, { nodeId: nid("blocked-review") }); + const blockedGreen = ctx.outputMaybe(tables.blocked, { nodeId: nid("blocked-green") }); + const blockedBase = ctx.outputMaybe(tables.blocked, { nodeId: nid("blocked-base") }); + const startTerminal = !runStart || Boolean(openPr || blockedReview || blockedGreen || blockedBase); + + const prNumber = openPr?.number ?? input.prNumber; + const canFinish = runFinish && prNumber != null; + const reviewBotRow = ctx.outputMaybe(tables.reviewBot, { nodeId: nid("review-bot") }); + const finishTerminal = !canFinish || Boolean(reviewBotRow); + + const showResult = startTerminal && finishTerminal; + + return ( + <> + {/* ── Step 0: resolve the unit + config from .rig/config.json ── */} + + {async () => { + const def = { + baseRef: "origin/main", + testCommand: "npm test", + trackerProvider: "none", + ticketPrefix: "", + reviewBot: "none", + maxRounds: 5, + defaultBranch: "main", + }; + let cfg: any = {}; + try { + cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); + } catch { + /* unconfigured fallback — tracker none, npm test, origin/main */ + } + const ticketPrefix = cfg?.tracker?.ticketPrefix ?? def.ticketPrefix; + const provider = cfg?.tracker?.provider ?? def.trackerProvider; + const target = input.target.trim(); + const looksLikeTicket = ticketPrefix && new RegExp(`^${ticketPrefix}\\d+$`, "i").test(target); + const isAdHoc = provider === "none" || (target !== "" && !looksLikeTicket); + const baseRef = input.base.trim() || cfg?.vcs?.baseRef || def.baseRef; + const unit = target || "(infer from branch)"; + return { + unit, + isAdHoc, + baseRef, + testCommand: cfg?.test?.command ?? def.testCommand, + trackerProvider: provider, + ticketPrefix, + reviewBot: cfg?.review?.bot ?? def.reviewBot, + maxRounds: cfg?.review?.maxRounds ?? def.maxRounds, + defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, + summary: `rig-task ${input.phase}: ${isAdHoc ? `ad-hoc "${unit}"` : unit} → base ${baseRef}, tests \`${cfg?.test?.command ?? def.testCommand}\`, bot ${cfg?.review?.bot ?? def.reviewBot}`, + }; + }} + + + {/* ── START phase: steps 1-5 ── */} + {runStart ? ( + + {/* Step 1 — load spec + set up an isolated worktree */} + + {(d: any) => { + const p = d[nid("preflight")]; + return `You are executing Step 1 of the /rig-task skill. Read ${SKILL} (Step 1) and \`.rig/config.json\` first, then: + +0. BASE GUARD — do this BEFORE creating any worktree. Determine whether this ticket is an epic CHILD: its tracker parent is an epic, or an integration branch \`-*\` for its parent already exists on origin (\`git ls-remote --heads origin\`). ${input.base ? `A stacked base (\`${input.base}\`) was provided, so a child is fine — set epicChildMismatch=false.` : "NO stacked base was provided (base is the trunk)."} If it IS an epic child AND no stacked base was provided, STOP immediately: set epicChildMismatch=true, suggestedBase=, do NOT create a worktree, leave the spec fields empty, and return. Otherwise set epicChildMismatch=false and suggestedBase="". + +1. Load the spec for: ${p.isAdHoc ? `the ad-hoc task "${input.target}"` : `ticket ${input.target || "(infer from the current branch)"}`}. + - Tracker "${p.trackerProvider}": fetch the issue (Linear MCP get_issue / \`gh issue view\`) and set it to "In Progress" now (idempotent; do this even under githubIntegration). Ad-hoc: the description IS the spec — if it's a one-liner, expand acceptance criteria. +2. Set up an isolated checkout via the /rig-worktree skill (do NOT inline \`git worktree add\`). Branch from \`${p.baseRef}\`${input.base ? " (a STACKED base — target the PR at it, not the trunk)" : ""}. Use the tracker's suggested gitBranchName verbatim when present, else vcs.branchConvention. Name the session "FEAT:"/"CHORE:" with --skip-if-prefix "EPIC:". + +CALLER DIRECTION — treat as authoritative spec resolution and **fold it into the specDescription AND acceptanceCriteria you return** (downstream RED/GREEN read those), applying only the parts relevant to THIS unit: +${input.specNotes?.trim() ? input.specNotes : "(none)"} + +Restate the acceptance criteria (with the direction folded in) back to yourself before finishing. Return the absolute worktree path, the branch name, the spec title/description, the acceptance criteria (as text), the tracker state you set, epicChildMismatch, and suggestedBase.`; + }} + + + {/* Base guard: epic child on the trunk — stop before spending any review/TDD agents. */} + {setup && specMismatch ? ( + + {(d: any) => ({ + stage: "base", + reason: "Epic child launched against the trunk — it must stack on the integration branch", + detail: `Re-run with base=${d[nid("setup")].suggestedBase || ""} (or drive it via rig-epic). No worktree created; no review/TDD agents spent.`, + })} + + ) : null} + + {/* Everything past setup runs only when the base is right. */} + {setup && !specMismatch ? ( + + {/* Step 2 — spec review: architect + qa in parallel */} + + + {(d: any) => { + const s = d[nid("setup")]; + return `You are the ARCHITECT reviewing a spec for implementability (Step 2 of ${SKILL}). ${cd(s.worktreePath)} + +Spec: +${s.specTitle} + +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +Identify ambiguities, missing acceptance criteria, the files that must change, and a suggested implementation order. Return your notes, and a \`blockers\` array listing ONLY things that must be resolved BEFORE coding can start (empty if none).`; + }} + + + {(d: any) => { + const s = d[nid("setup")]; + return `You are QA reviewing a spec from a testing perspective (Step 2 of ${SKILL}). ${cd(s.worktreePath)} + +Spec: +${s.specTitle} + +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +What test cases are needed? Are the acceptance criteria testable? What edge cases matter? Return a \`testPlan\`, and a \`blockers\` array of ONLY criteria that are untestable/contradictory and must be fixed before coding (empty if none).`; + }} + + + + {/* Spec-review gate: pause for a human only when a blocker was flagged. */} + {specHasBlockers ? ( + + ) : null} + + {/* Step 3 — RED: failing tests first */} + + {(d: any) => { + const s = d[nid("setup")]; + return `You are executing Step 3 (RED) of ${SKILL}. ${cd(s.worktreePath)} + +Write tests for this unit BEFORE any implementation. Cover every acceptance criterion plus the edge cases the architect flagged. Do NOT stub or comment out — the tests must compile and FAIL for the right reason (missing implementation), not a syntax error. Match the project's test framework and colocation conventions. + +Spec: +${s.specTitle} +${s.specDescription} + +Acceptance criteria: +${s.acceptanceCriteria} + +Architect notes: +${d[nid("spec-architect")].notes} + +QA test plan: +${d[nid("spec-qa")].testPlan} + +Run \`${ctx.outputMaybe(tables.preflight, { nodeId: nid("preflight") })?.testCommand ?? "the test command"}\` from the worktree. VERIFY RED: each new test must fail with a message reflecting the missing behavior. A new test that passes immediately was pinning existing behavior — rewrite it. Return redVerified=true only when the suite fails for the right reason, plus the test output and a short summary.`; + }} + + + {/* Step 4 — GREEN: minimum implementation, up to 3 iterations */} + + + {(d: any) => { + const s = d[nid("setup")]; + const prev = ctx.latest(tables.green, nid("green-step")); + return `You are executing Step 4 (GREEN) of ${SKILL}. ${cd(s.worktreePath)} + +Make the failing tests pass with the MINIMUM change — no features not required by a test. Explore the affected files first and prefer EXTENDING or REUSING existing code over a parallel implementation. + +Spec: +${s.specTitle} +${s.specDescription} + +Failing tests (from RED): +${d[nid("red-step")].testOutput} +${prev ? `\nPrevious GREEN attempt still failing with:\n${prev.testOutput}\n(fix these remaining failures)` : ""} + +Re-run the full test suite from the worktree. If implementing exposes a missing edge case, add that test first rather than piling untested behavior in. Return green=true only when the whole suite passes, plus the test output and a summary of what you changed.`; + }} + + + + + {/* Step 4.25 — REFACTOR (only while green) */} + + {(d: any) => { + const s = d[nid("setup")]; + return `You are executing Step 4.25 (REFACTOR) of ${SKILL}. ${cd(s.worktreePath)} + +The implementation is GREEN. If — and only if — there is obvious duplication, awkward naming, or a helper that wants extracting, make that cleanup with NO new behavior, then re-run the full suite to confirm it stays green. If the code is already clean, change nothing. Return changed (true/false) and a one-line summary.`; + }} + + + {/* Step 4.5 — pre-PR self-review gate (find -> fix -> re-find) */} + + + + {(d: any) => { + const s = d[nid("setup")]; + return `You are executing Step 4.5 (pre-PR self-review) of ${SKILL} — the FIND half. ${cd(s.worktreePath)} + +Run the /rig-review skill for this unit: walk \`${d[nid("preflight")] ? ".claude/REVIEWER.md" : "the review patterns file"}\` against \`git diff ${d[nid("preflight")].baseRef}...HEAD\` and return a triaged P0-P3 list. Count findings by severity: p0p1 (must-fix before merge), p2, p3. Set clean=true only when there are zero P0/P1. Return the findings text too.`; + }} + + + {(d: any) => { + const s = d[nid("setup")]; + const findings = ctx.latest(tables.reviewFind, nid("review-find"))?.findings ?? ""; + return `You are executing Step 4.5 of ${SKILL} — the FIX half (/rig-review fix, local). ${cd(s.worktreePath)} + +Fix every P0/P1 finding below, keeping the test suite green. Do NOT touch P2/P3 (they ship as follow-ups). + +Findings: +${findings} + +Return a short summary of what you changed. The loop will re-run the review to confirm convergence.`; + }} + + } + else={null} + /> + + + + {/* Step 5 — push + open the PR, only when the self-review is clean */} + + {(d: any) => { + const s = d[nid("setup")]; + const p = d[nid("preflight")]; + return `You are executing Step 5 of ${SKILL}. ${cd(s.worktreePath)} + +1. Commit all changes with a message referencing the unit. +2. Push the branch. +3. Open a PR with \`gh pr create\` targeting \`${input.base || p.defaultBranch}\`: + - Title carries the ticket id where a tracker is used, e.g. \`feat(${input.target || "SCOPE"}): …\`. + - Body: a summary; the tracker link (\`Fixes \` for Linear / \`Closes #\` for GitHub); a test plan; and an \`## Architecture\` section stating any new abstraction/package/dependency/migration or "No architectural change." and WHY existing code wasn't reused. +4. TRACKER LINK + TRANSITION (adaptive — works with OR without a live Linear↔GitHub integration; do NOT trust the githubIntegration config flag, check reality): + - Ensure the PR is linked to the issue: \`get_issue\`; if no attachment already references this PR URL (the integration may have added one), \`create_attachment\` with the PR URL. + - Ensure the issue is In Review: \`get_issue\`; if it is NOT already In Review or further along (Done), \`save_issue state="In Review"\`. If the integration already advanced it, leave it — never clobber a further-along state. +${autoMerge + ? `5. AUTO-MERGE (enabled), self-review is clean: land this PR with **squash** (\`gh pr merge --squash --delete-branch\`, targeting \`${input.base || p.defaultBranch}\`). If any required check is pending, arm it CI-gated instead: add \`--auto\`. If there are NO required checks (auto-merge can't be armed / the PR is already mergeable), merge directly with the same command minus \`--auto\`. (If \`vcs.protectedBranchMergeQueue\` is true, use \`--auto\` with NO method flag — the queue decides.) Always squash; never rebase. Don't merge manually beyond this.` + : `5. Do NOT \`gh pr merge\` — this run does not auto-merge; the PR waits for a human.`} + +Return the PR number, URL, and title.`; + }} + + } + else={ + /* No hard dep on the looped review-find (would deadlock nested in an outer Loop); + the enclosing Sequence orders this after the review loop, findings via ctx.latest. */ + + {() => ({ + stage: "self-review", + reason: "P0/P1 findings unresolved after the max review rounds", + detail: ctx.latest(tables.reviewFind, nid("review-find"))?.findings ?? "", + })} + + } + /> + + } + else={ + + {() => { + const g = ctx.latest(tables.green, nid("green-step")); + return { + stage: "green", + reason: "Tests still failing after 3 GREEN iterations — spec likely wrong or an unstated constraint", + detail: g?.testOutput ?? "(no test output captured)", + }; + }} + + } + /> + + ) : null} + + ) : null} + + {/* ── FINISH phase: step 6 (review-bot loop) ── */} + {canFinish ? ( + + {(d: any) => { + const p = d[nid("preflight")]; + const wt = setup?.worktreePath; + return `You are executing Step 6 (review-bot loop) of ${SKILL} for PR #${prNumber}. ${wt ? cd(wt) : "First re-establish context: resolve the open PR's worktree from PR #" + prNumber + " / the current branch and cd into it."} + +Review bot: \`${p.reviewBot}\`. ${input.local ? "The --local flag is set: force the local /rig-review fix loop." : ""} +- reviewBot "none" → nothing to drive; the Step 4.5 local gate was the whole review. outcome="clean". +- A cloud auto-fix workflow is enabled and --local was NOT passed → WATCH only (do not fix or push). Poll the PR (~60s, up to ~30min) until the bot's review reaches a terminal state; report it. +- Otherwise → DRIVE via the /rig-review fix loop: poll + classify the bot's review, fix via a coding agent, commit, push, re-trigger (\`${p.reviewBot === "bugbot" ? "bugbot run" : "the configured retrigger"}\`), up to ${p.maxRounds} rounds. + +Return outcome as exactly one of: "clean" (no actionable issues — merge gates take over), "actionable" (feedback remains after the last round), or "timeout" (bot didn't respond). Do NOT merge. Include a short detail string.`; + }} + + ) : null} + + {/* ── Step 7 — hand back (report only; never merges). Terminal lands in tables.result. ── */} + {showResult ? ( + + {() => { + const bot = ctx.outputMaybe(tables.reviewBot, { nodeId: nid("review-bot") }); + const g = ctx.latest(tables.green, nid("green-step")); + const rev = ctx.latest(tables.reviewFind, nid("review-find")); + const blk = blockedBase ?? blockedGreen ?? blockedReview; + let outcome = "pr-open"; + if (blk) outcome = "blocked"; + else if (bot) outcome = bot.outcome; + const testsGreen = g?.green === true; + const reviewState = rev ? `${rev.p0p1} P0/P1 + ${rev.p2} P2 + ${rev.p3} P3` : "n/a"; + const unit = pre?.unit ?? input.target; + const prUrl = openPr?.url ?? ""; + const mergeNote = autoMerge ? "squash-merge enabled (CI-gated if checks exist, else direct)" : "not merged (waits for a human)"; + const summary = blk + ? `BLOCKED at ${blk.stage}: ${blk.reason}.${blk.stage === "base" ? "" : " No PR opened."}` + : bot + ? `PR ${prUrl} — review-bot outcome: ${bot.outcome}. ${bot.outcome === "clean" ? `Merge gate: ${mergeNote}.` : "Left for a human."}` + : `PR opened: ${prUrl}. tests: ${testsGreen ? "green" : "red"}, review: ${reviewState}. ${mergeNote}.`; + return { outcome, unit, prUrl, testsGreen, reviewState, summary }; + }} + + ) : null} + + ); +} diff --git a/smithers/workflows/rig-crank.tsx b/smithers/workflows/rig-crank.tsx new file mode 100644 index 0000000..04cd6fd --- /dev/null +++ b/smithers/workflows/rig-crank.tsx @@ -0,0 +1,188 @@ +// smithers-source: seeded +// smithers-metadata-version: 1 +// smithers-display-name: rig-crank — autonomous build loop (SKETCH) +// smithers-description: Drains a ticket backlog one unit at a time — advisor-picks the next ready ticket, builds it via rig-task, verifies with evidence-based backpressure, lands it, and carries distilled state across generations until the backlog is dry or the budget is spent. First-pass sketch. +// smithers-tags: rig, autonomous, loop, sketch +/** @jsxImportSource smithers-orchestrator */ +import { createSmithers, Aspects } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../agents"; +import { TaskFlow, taskSchemas, taskBag, taskResultSchema } from "./flows/task-flow"; +import { EpicFlow, epicSchemas, epicBag } from "./flows/epic-flow"; + +/** + * rig-crank — an autonomous build loop grounded in Smithers context-engineering: + * pick → build → verify → land → repeat, until the backlog is dry or the budget's spent. + * + * Theses it embodies (from smithers.sh/guides/context-engineering): + * - "An agent is a loop that manufactures a better context window." Each turn resolves + * the next unit and hands the child a clean, sufficient spec. + * - Keep the ORCHESTRATOR lean (<100k): nodes exchange TYPED SUMMARIES, never raw diffs; + * the durable state lives in the TRACKER, not in orchestrator memory. + * - Backpressure is the gate: a unit suite + a REQUIRED e2e decide "done", not vibes. + * - Split decision from act: verify (reversible) is separate from land (irreversible), gated last. + * - Evidence-based red vs infra: a nonzero exit with no failure evidence is infra → retry, not a defect. + * - Longevity: the Loop's own `continueAsNewEvery` /clears and carries state across generations. + * + * Hardening path (native components that replace the hand-rolled bits, once the core proves out): + * - — serialize the irreversible LAND across concurrent cranks (one merge at a time). + * - / — compensating rollback if a land half-completes. + * - — advisor decides autonomously, but escalates to a human on genuine uncertainty + * (resolves "must I always drive this myself?" — hands off only the hard calls). + * - — guards the long-run "goal has blurred 50 turns in" failure mode. + * - — a ready-made build→verify→fix inner loop, if we want retries inside a unit. + */ + +// Both branches compose their flow INLINE (one run, native deps) — no childRun Subflow anywhere. +const SKILL = ".claude/skills/rig-task/SKILL.md"; + +const SMART = providers.claudeOpus; // judgment: pick + verify +const CHEAP = providers.claudeSonnet; // mechanical: land + report + +const inputSchema = z.object({ + scope: z.string().default("").describe("Which tickets are in play — a milestone/label/query the picker drains (e.g. 'Polish'). Empty = all ready backlog."), + maxUnits: z.number().int().default(8).describe("Safety ceiling on units this run; the loop also stops when the backlog is dry or the token budget is spent."), + advisor: z.boolean().default(true).describe("Autonomous: gate decisions are made by an advisor, no human waits. false → park for a human."), + built: z.array(z.string()).default([]).describe("Optional seed of already-landed tickets (for resumes); the picker's real source of truth is the tracker's Done state."), +}); +const resultSchema = z.object({ done: z.boolean(), built: z.array(z.string()), note: z.string() }); + +// Lean, TYPED node interfaces — this is all the orchestrator ever sees (never raw diffs/logs). +const { Workflow, Sequence, Parallel, Task, Loop, Branch, smithers, outputs } = createSmithers({ + input: inputSchema, + result: resultSchema, + // Inline-composition tables (no collisions): canonical TaskFlow (task branch), + // namespaced child_ TaskFlow (the epic branch's children) + epic_ EpicFlow (epic branch). + ...taskSchemas(), + ...taskSchemas("child"), + ...epicSchemas("epic"), + pick: z.object({ ticketId: z.string(), ready: z.boolean(), shape: z.enum(["epic", "task"]).default("task"), rationale: z.string() }), // ticketId "" = backlog dry; shape routes EpicFlow vs TaskFlow + taskUnit: taskResultSchema, // where the inline TaskFlow terminal lands (task branch) + probe: z.object({ lens: z.string(), riskFound: z.boolean(), evidence: z.string() }), // DeriskLoop-pattern risk probes + verify: z.object({ green: z.boolean(), kind: z.string(), evidence: z.string() }), // kind: unit | e2e | risk | infra + land: z.object({ ticketId: z.string(), merged: z.boolean(), detail: z.string() }), + result: resultSchema, +}); + +export default smithers((ctx) => { + const advisor = ctx.input.advisor !== false; + const seed = ctx.input.built.join(", ") || "(none)"; + + const pick = ctx.latest(outputs.pick, "pick"); + const backlogDry = Boolean(pick) && (pick.ready === false || (pick.ticketId ?? "") === ""); + const verify = ctx.latest(outputs.verify, "verify"); + const verifyGreen = verify?.green === true; + + // Bags for the inline EpicFlow (epic branch): epic tables namespaced "epic_", its + // children's TaskFlow tables namespaced "child_"; the child terminals land in epic_childRun. + const epicTables = epicBag(outputs, "epic"); + const childTablesForEpic = taskBag(outputs, epicTables.childRun, "child"); + + return ( + + {/* Keep the ORCHESTRATOR lean so it can run all day: a hard token ceiling on the whole run; + children summarize into it rather than dumping diffs/logs. */} + + {/* The crank. Stop when the backlog is dry (until), or the safety ceiling (maxIterations); + /clear and carry loop state every 3 units so context never bloats across a long drain. */} + + + {/* 1 · PICK — advisor names the next READY, TOP-LEVEL work item and classifies its SHAPE. + Top-level only: children of an epic are drained INSIDE rig-epic, never picked here, or + they'd be built twice. Shape = the rig interleave test: a parent whose children interleave + (one child's runtime contract depends on another's incomplete state) is an "epic"; a + standalone leaf is a "task". Source of truth is the TRACKER. */} + + {() => `From scope "${ctx.input.scope || "the backlog"}", pick the single next READY, TOP-LEVEL item to build (top-level = it has no parent epic of its own; skip child tickets — those are handled inside their epic). Ready = all its blockedBy are Done. Classify \`shape\`: "epic" if it's a parent whose children interleave (one child's runtime contract depends on another's incomplete state — the rig epic test), else "task" for a standalone unit. Query the tracker; skip anything already Done or in this seed list: ${seed}. Return {ticketId, ready, shape, rationale}; set ticketId="" and ready=false if nothing is ready (backlog dry).`} + + + {/* Build only when there's a ready pick; otherwise `until` (backlogDry) ends the loop next check. */} + + } + else={ + /* TASK — the crank owns the gate: build → e2e verify → land. */ + + {/* 2 · BUILD — TaskFlow composed INLINE (one run, native deps, full time-travel; + no childRun Subflow). idPrefix "task-" namespaces its nodes; its terminal + summary lands in outputs.taskUnit. phase "both" runs the FULL rig-task incl. the + review-bot (Bugbot) loop, so the unit is bot-clean BEFORE the crank verifies+lands. + spec pre-cleared so it never pauses. */} + + + {/* 3 · VERIFY — a DeriskLoop-PATTERN risk-probe gate (the component is + welded to the delegation framework, so we adapt its shape). Two independent, + adversarial probes run in parallel, then a verdict folds them in with the unit+e2e + check. Ordering is by ; the verdict reads the probes via suffix-lenient + ctx.latest (NOT a hard dep — those deadlock on looped/sibling nodes, per the fix). */} + + + + {(d) => `Adversarially probe ${pick?.ticketId} (PR ${d["task-result"].prUrl || "n/a"}) for REGRESSIONS: inspect/exercise the existing behaviors the change touches and try to find one it silently breaks. Return {lens:"regression", riskFound, evidence}. riskFound=true ONLY with concrete evidence of a broken behavior (a failing case, a changed output that shouldn't have); default false.`} + + + {(d) => `Adversarially probe ${pick?.ticketId} (PR ${d["task-result"].prUrl || "n/a"}) for CONTRACT/CALLER breaks: does it change a wire/type/API contract, or leave a caller, schema, or downstream consumer unupdated? Return {lens:"contract", riskFound, evidence}. riskFound=true ONLY with a concrete unupdated caller / broken contract; default false.`} + + + + {(d) => { + const reg = ctx.outputMaybe(outputs.probe, { nodeId: "probe-regression" }); + const con = ctx.outputMaybe(outputs.probe, { nodeId: "probe-contract" }); + return `Final verify for ${pick?.ticketId} (PR ${d["task-result"].prUrl || "n/a"}), in its worktree (see ${SKILL}). Run the unit suite AND drive the real end-to-end path. Also weigh the two risk probes: +- regression: riskFound=${reg?.riskFound ?? "?"} — ${reg?.evidence ?? "(pending)"} +- contract: riskFound=${con?.riskFound ?? "?"} — ${con?.evidence ?? "(pending)"} +Set green=true ONLY if the unit suite AND e2e pass AND neither probe confirmed a real risk. Else set \`kind\`: "unit"/"e2e" for a genuine test failure (real \`error TS…\` / failed-test evidence), "risk" if a probe confirmed a real regression/contract break, or "infra" for a nonzero exit with NO such evidence (flaky env) — infra is NOT a defect. Put the deciding evidence in \`evidence\`.`; + }} + + + + {/* 4 · LAND — the irreversible act, gated LAST on a green verify. Decision split from act. */} + + {(d) => `Land ${pick?.ticketId}: squash-merge the PR (${d["task-result"].prUrl}) to the trunk once its required checks pass (CI is the gate; merge directly if there are none), then mark the ticket Done (adaptive — defer if a live integration already closed it). Return {ticketId, merged, detail}.`} + + } + else={ + + {(d) => ({ ticketId: pick?.ticketId ?? "", merged: false, detail: `not landed — ${d["verify"].kind}: ${d["verify"].evidence.slice(0, 300)}` })} + + } + /> + + } + /> + } + else={null} + /> + + + + + {/* Terminal report — read the tracker (durable source of truth), don't reconstruct from memory. */} + + {() => `The crank has stopped for scope "${ctx.input.scope || "the backlog"}". Report {done, built, note}: done=true if the backlog is dry (no ready tickets remain), false if it stopped on the ceiling/budget. \`built\` = the tickets this run moved to Done (check the tracker + merged PRs). Keep \`note\` to one line.`} + + + ); +}, { output: outputs.result }); diff --git a/smithers/workflows/rig-delegation-spike.tsx b/smithers/workflows/rig-delegation-spike.tsx new file mode 100644 index 0000000..26efa7c --- /dev/null +++ b/smithers/workflows/rig-delegation-spike.tsx @@ -0,0 +1,59 @@ +// smithers-source: seeded +// smithers-metadata-version: 1 +// smithers-display-name: rig-delegation-spike — DelegationChain evaluation +// smithers-description: SPIKE. Points Smithers' off-the-shelf DelegationChain (recursive tiered delegation: refine → decompose → derisk → execute → score) at a single ask, to evaluate whether the suite could replace hand-built rig-crank/rig-epic orchestration. Not wired to the tracker or the integration-branch model — that's the open question. +// smithers-tags: rig, delegation, spike, evaluation +/** @jsxImportSource smithers-orchestrator */ +import { createSmithers, DelegationChain, delegationSchemas } from "smithers-orchestrator"; +import { z } from "zod/v4"; +import { providers } from "../agents"; + +/** + * SPIKE — is DelegationChain a shortcut to the autonomous build loop, or a mismatch? + * + * DelegationChain is the composite behind the `delegation-chain` workflow: a recursive, + * self-decomposing delegation engine. From ONE prompt it runs seven reactive phases — + * goal refinement, recursive decomposition (fan-out until the frontier is all leaves), + * derisk probes, dependency-ordered leaf execution with gates + budgets, and scoring — + * replanning affected subtrees as rows land, without restarting. + * + * What we get for free (things we hand-build in rig-crank/rig-epic): + * - recursive decomposition (rig-epic's plan) + level-by-level fan-out + * - per-node backpressure, budgets (maxUsd/maxMinutes → Aspects), scoring + * - tiered model routing (strongest-first with fallback) — our ROLE map, generalized + * - live edits + derisk replanning mid-run + * + * What it does NOT know (the rig-specific substance — the open question): + * - the TRACKER: Linear tickets, blockedBy, adaptive Done transitions + * - the integration-branch / stacked-PR model and squash-to-trunk + * - the rig ROLES as a *process* (architect → TDD coder → qa → reviewer), not just tiers + * - TDD (RED→GREEN→REFACTOR), the pre-PR self-review, the review-bot loop + * - worktree isolation per unit (though / compose in) + * + * Tiers are LABELS, not model ids; missing tiers fall back to the nearest in tierOrder. + * Map them onto our providers so the spike uses our real agents. + */ + +const inputSchema = z.object({ + prompt: z.string().default("").describe("The ask to hand the delegation engine (e.g. a Polish ticket's spec pasted in, or 'Implement CEX-551: true fractional matching in the mock-venue engine')."), +}); + +const { Workflow, smithers, outputs } = createSmithers({ input: inputSchema, ...delegationSchemas }); + +export default smithers((ctx) => ( + + + +)); diff --git a/smithers/workflows/rig-epic.tsx b/smithers/workflows/rig-epic.tsx index 6c54867..d782e8c 100644 --- a/smithers/workflows/rig-epic.tsx +++ b/smithers/workflows/rig-epic.tsx @@ -1,543 +1,37 @@ // smithers-source: seeded // smithers-metadata-version: 1 // smithers-display-name: rig-epic — integration-branch workflow -// smithers-description: Canonicalizes the /rig-epic skill as a durable graph — decompose a feature into parent + children, stack each child PR (via the rig-task sub-workflow) on a shared integration branch, review the combined diff across three lenses, then squash to the trunk. Never auto-merges the trunk without opt-in. +// smithers-description: Canonicalizes the /rig-epic skill as a durable graph — decompose a feature into parent + children, stack each child PR (inline TaskFlow) on a shared integration branch, review the combined diff across three lenses, then squash to the trunk. Never auto-merges the trunk without opt-in. Thin wrapper over the composable EpicFlow fragment. // smithers-tags: rig, epic, integration-branch, stacked-prs /** @jsxImportSource smithers-orchestrator */ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { createSmithers, Subflow, HumanTask } from "smithers-orchestrator"; -import { z } from "zod/v4"; -import { providers } from "../agents"; +import { createSmithers, UI } from "smithers-orchestrator"; +import { EpicFlow, epicSchemas, epicBag, epicInputSchema, epicResultSchema } from "./flows/epic-flow"; +import { taskSchemas, taskBag } from "./flows/task-flow"; /** - * Role -> model, matched to .claude/agents/rig-*.md (architect/reviewer = opus, - * qa/coder = sonnet). Single Claude model per role, NOT the codex/fable-leading - * pools in agents.ts. `coord` = orchestration steps with no rig role (git/gh, - * state file, merge gate) -> conservative sonnet. + * Standalone rig-epic = the EpicFlow fragment under its own . The graph + * lives in flows/epic-flow.tsx so rig-crank can compose it INLINE (one run, native + * deps, no childRun). Register EpicFlow's own tables + the child TaskFlow tables + * (namespaced "child_" — no collision with epic's own reviewFix/etc.). */ -const ROLE = { - architect: providers.claudeOpus, - reviewer: providers.claudeOpus, - qa: providers.claudeSonnet, - coder: providers.claudeSonnet, - coord: providers.claudeSonnet, - // `advisor` = the autonomous arch gate: Fable stands in for the human at the - // front-loaded spec gate so a kicked-off epic runs unattended (see `advisor` input). - advisor: providers.claude, -} as const; - -// Reference the rig-task workflow by file (resolved relative to THIS module, so -// it is cwd-independent). Loaded from the approved root when a child node runs. -const WORKFLOWS_DIR = new URL(".", import.meta.url).pathname; -const RIG_TASK_REF = { - path: resolve(WORKFLOWS_DIR, "rig-task.tsx"), - approvedRoot: resolve(WORKFLOWS_DIR, ".."), -}; - -/** - * Canonical graph of the /rig-epic skill (.claude/skills/rig-epic/SKILL.md). - * - * epic-preflight - * plan -> start (phase plan|full: steps `plan` + `start`) - * (approve-run gate, full only) - * run: for each child in dependency (topological) order — - * Subflow(rig-task --base ) -> merge-gate (phase run|full) - * review: [simplify | cross-pr | dead-code] -> consolidate -> approve (phase review|full) - * finish: review gate -> squash PR -> (optional --merge) (phase finish|full) - * - * Each child is the rig-task workflow run against the integration branch, so - * the two canonicalized skills compose exactly as /rig-epic delegates to - * /rig-task in prose. - */ - -const childSchema = z.object({ - id: z.string(), - title: z.string(), - blockedBy: z.array(z.string()).default([]), - status: z.string().default("todo"), -}); -type Child = z.infer; - -const inputSchema = z.object({ - phase: z - .enum(["plan", "run", "review", "finish", "full"]) - .default("full") - .describe("full (default) = the whole arc plan→run→review→finish as ONE durable run, pausing only at in-graph approval gates. plan/run/review/finish are partial entry points; finish always runs the review gate first. An existing epic (parent + state, no feature) skips planning and resumes from what's already merged."), - feature: z.string().default("").describe("The feature to decompose (phase plan/full)."), - parent: z.string().default("").describe("Parent id or integration branch to resume from (phase run/review/finish). Empty = infer the single active epic."), - merge: z.boolean().default(false).describe("finish --merge: squash-merge the final PR to the trunk instead of stopping at an open PR."), - advisor: z - .boolean() - .default(false) - .describe("Autonomous arch gate. When true, the front-loaded spec gate (spec-direction) is decided by a Fable advisor instead of a human: it reads the architect+QA specs, then either proceed=true with synthesized per-child direction, or proceed=false → the epic HALTS with a blocked report (no human ever waits). Lets parallel epics run unattended to child PRs."), -}); - -const epicResultSchema = z.object({ - phase: z.string(), - integrationBranch: z.string(), - prUrl: z.string(), - summary: z.string(), -}); - -// Mirror of rig-task's designated result — the shape Subflow persists per child. -const childRunSchema = z.object({ - outcome: z.string(), - unit: z.string(), - prUrl: z.string(), - testsGreen: z.boolean(), - reviewState: z.string(), - summary: z.string(), -}); - -const { Workflow, Sequence, Parallel, Task, Branch, Approval, UI, smithers, outputs } = createSmithers({ - input: inputSchema, +const { Workflow, smithers, outputs } = createSmithers({ + input: epicInputSchema, output: epicResultSchema, - epicPreflight: z.object({ - baseRef: z.string(), - defaultBranch: z.string(), - trackerProvider: z.string(), - integrationBranch: z.string(), - parent: z.string(), - whyEpic: z.string(), - childrenJson: z.string(), - source: z.string(), - banner: z.string(), - }), - plan: z.object({ - parent: z.string(), - parentTitle: z.string(), - integrationBranch: z.string(), - whyEpic: z.string(), - childrenJson: z.string(), - summary: z.string(), - }), - start: z.object({ integrationBranch: z.string(), stateFile: z.string(), summary: z.string() }), - specDirection: z.object({ proceed: z.boolean(), direction: z.string() }), - epicSpec: z.object({ role: z.string(), blockers: z.array(z.string()), notes: z.string() }), - childRun: childRunSchema, - merge: z.object({ childId: z.string(), merged: z.boolean(), prUrl: z.string(), detail: z.string() }), - reviewLens: z.object({ lens: z.string(), p0p1: z.number().int(), p2: z.number().int(), findings: z.string() }), - reviewConsolidated: z.object({ p0p1: z.number().int(), p2: z.number().int(), clean: z.boolean(), report: z.string() }), - reviewApproval: z.object({ approved: z.boolean() }), - reviewFix: z.object({ summary: z.string() }), - finishApproval: z.object({ approved: z.boolean() }), - squashPr: z.object({ number: z.number().int(), url: z.string() }), - epicResult: epicResultSchema, + ...epicSchemas(), + ...taskSchemas("child"), }); -const SKILL = ".claude/skills/rig-epic/SKILL.md"; - -/** Kahn topological sort over blockedBy edges; falls back to declaration order on a cycle. */ -function topoOrder(children: Child[]): Child[] { - const byId = new Map(children.map((c) => [c.id, c])); - const indeg = new Map(children.map((c) => [c.id, 0])); - for (const c of children) { - for (const dep of c.blockedBy) { - if (byId.has(dep)) indeg.set(c.id, (indeg.get(c.id) ?? 0) + 1); - } - } - const queue = children.filter((c) => (indeg.get(c.id) ?? 0) === 0); - const ordered: Child[] = []; - const seen = new Set(); - while (queue.length) { - const c = queue.shift()!; - if (seen.has(c.id)) continue; - seen.add(c.id); - ordered.push(c); - for (const other of children) { - if (other.blockedBy.includes(c.id)) { - indeg.set(other.id, (indeg.get(other.id) ?? 0) - 1); - if ((indeg.get(other.id) ?? 0) === 0) queue.push(other); - } - } - } - return ordered.length === children.length ? ordered : children; -} - -function parseChildren(json: string | undefined): Child[] { - if (!json) return []; - try { - const raw = JSON.parse(json); - return Array.isArray(raw) ? raw.map((r) => childSchema.parse({ blockedBy: [], ...r })) : []; - } catch { - return []; - } -} - -export default smithers((ctx) => { - const { phase, merge, advisor } = ctx.input; - const isFull = phase === "full"; - // `full` is the continuous default: it drives plan→run→review→finish, pausing - // ONLY at in-graph approval gates (no agent stitching phases together). - // Plan only when a feature was given; an existing epic (parent + state) skips - // straight to run/review/finish based on what's already merged. - const doPlan = (phase === "plan" || isFull) && ctx.input.feature.trim() !== ""; - const doRun = phase === "run" || isFull; - const doReview = phase === "review" || phase === "finish" || isFull; // review folds INTO finish - const doFinish = phase === "finish" || isFull; - - const pre = ctx.outputMaybe(outputs.epicPreflight, { nodeId: "epic-preflight" }); - const planRow = ctx.outputMaybe(outputs.plan, { nodeId: "plan" }); - const startRow = ctx.outputMaybe(outputs.start, { nodeId: "start" }); - - // The integration branch + children come from the plan (fresh) or the state file (existing epic). - const integrationBranch = planRow?.integrationBranch || pre?.integrationBranch || ""; - const children = topoOrder(parseChildren(planRow?.childrenJson ?? pre?.childrenJson)); - - // A child counts as merged if the state file already says so, or its run-loop merge node recorded it. - const merged = (c: Child) => c.status === "merged" || ctx.outputMaybe(outputs.merge, { nodeId: `merge-${c.id}` })?.merged === true; - const allMerged = children.length > 0 && children.every(merged); - const startedFresh = doPlan; // a fresh plan cuts the branch first - - // FRONT-LOADED SPEC REVIEW: review ALL children's specs up front (architect + qa), - // gate on ONE approval, then run children with their own spec gate OFF. A child - // must never pause mid-run — a paused Subflow child fails the whole epic. - const specArch = ctx.outputMaybe(outputs.epicSpec, { nodeId: "epic-spec-architect" }); - const specQa = ctx.outputMaybe(outputs.epicSpec, { nodeId: "epic-spec-qa" }); - const specReviewed = Boolean(specArch && specQa); - const specBlockers = [...(specArch?.blockers ?? []), ...(specQa?.blockers ?? [])]; - const specHasBlockers = specBlockers.length > 0; - // The spec gate produces free-form direction (not just approve/deny): a human - // (HumanTask) by default, or a Fable advisor (Task) when `advisor` is set for - // unattended runs. Either way `direction` is threaded into every child's coder; - // `proceed` gates execution (false = halt with a blocked report). - const specDir = ctx.outputMaybe(outputs.specDirection, { nodeId: "spec-direction" }); - const specAnswered = Boolean(specDir); - const runApproved = specDir?.proceed === true; - const direction = specDir?.direction ?? ""; - // Spec review can run once the branch + children are known (start done for a fresh epic). - const specReviewReady = doRun && integrationBranch !== "" && children.length > 0 && !allMerged && (!startedFresh || Boolean(startRow)); - // Children execute only after the human answers the spec gate with proceed=true. - const readyToRun = specReviewReady && specReviewed && runApproved; - - const consolidated = ctx.outputMaybe(outputs.reviewConsolidated, { nodeId: "review-consolidate" }); - const reviewClean = consolidated?.clean === true; - const reviewApproved = ctx.outputMaybe(outputs.reviewApproval, { nodeId: "approve-review" })?.approved === true; - // Review runs once all children are in (or immediately for an already-merged epic). - const reviewReady = doReview && integrationBranch !== "" && allMerged; - - const finishGatePassed = reviewClean || reviewApproved; // review is a HARD gate before the squash PR - const finishApproved = isFull ? ctx.outputMaybe(outputs.finishApproval, { nodeId: "approve-finish" })?.approved === true : true; - const finishReady = doFinish && finishGatePassed && (!isFull || finishApproved); - - const cd = integrationBranch ? `The integration branch is \`${integrationBranch}\`.` : ""; - - return ( +export default smithers( + (ctx) => ( - - {/* Resolve config + (for run/review/finish) the active epic state file. */} - - {async () => { - const def = { baseRef: "origin/main", defaultBranch: "main", trackerProvider: "none" }; - let cfg: any = {}; - try { - cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); - } catch { - /* unconfigured fallback */ - } - // For run/review/finish, read the epic state file. Search THIS worktree and - // (fallback) the main checkout, since /rig-epic may have been run elsewhere. - let integrationBranch = ""; - let parent = ctx.input.parent; - let whyEpic = ""; - let childrenJson = "[]"; - let source = "none"; - const epicDirs = [resolve(process.cwd(), ".rig/epics")]; - try { - const { execSync } = await import("node:child_process"); - // The main worktree's .git parent → its .rig/epics. - const commonGit = execSync("git rev-parse --path-format=absolute --git-common-dir", { encoding: "utf8" }).trim(); - const mainRepo = resolve(commonGit, ".."); - const mainEpics = resolve(mainRepo, ".rig/epics"); - if (!epicDirs.includes(mainEpics)) epicDirs.push(mainEpics); - } catch { - /* not a git repo / git missing — cwd dir only */ - } - try { - const { readdirSync, existsSync } = await import("node:fs"); - for (const dir of epicDirs) { - if (!existsSync(dir)) continue; - const files = (readdirSync(dir) as string[]).filter((f) => f.endsWith(".json")); - // A NAMED parent only matches a state file that references it (case-insensitive: - // parent may be the ticket id `CEX-553` or the branch slug `cex-553-…`). Never - // fall back to "the single existing file" when a parent is named — that would - // hijack a DIFFERENT epic's state (e.g. two epics running in parallel). - const needle = ctx.input.parent.trim().toLowerCase(); - const pick = needle - ? files.find((f) => f.toLowerCase().includes(needle)) - : files.length === 1 - ? files[0] - : undefined; - if (pick) { - const state = JSON.parse(readFileSync(resolve(dir, pick), "utf8")); - integrationBranch = state.integrationBranch ?? pick.replace(/\.json$/, ""); - parent = state.parent ?? parent; - whyEpic = state.whyEpic ?? ""; - childrenJson = JSON.stringify(state.children ?? []); - source = "state-file"; - break; - } - } - } catch { - /* no state file yet — plan will create one */ - } - // Last resort: no state file, but the caller named the integration branch via `parent`. - if (integrationBranch === "" && ctx.input.parent.trim() !== "") { - integrationBranch = ctx.input.parent.trim(); - source = "input-parent"; - } - const banner = `rig-epic: ${ctx.input.phase} — ${integrationBranch ? `epic ${integrationBranch}` : ctx.input.feature || "(new epic)"}. PRs target ${integrationBranch || "the integration branch"}, not the trunk. Will not auto-merge the trunk without --merge.`; - return { - baseRef: cfg?.vcs?.baseRef ?? def.baseRef, - defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, - trackerProvider: cfg?.tracker?.provider ?? def.trackerProvider, - integrationBranch, - parent, - whyEpic, - childrenJson, - source, - banner, - }; - }} - - - {/* ── plan + start ── */} - {doPlan ? ( - - - {(d) => { - const p = d["epic-preflight"]; - const namedParent = (ctx.input.parent || p.parent || "").trim(); - return `You are executing the \`plan\` step of the /rig-epic skill. Read ${SKILL} and \`.rig/config.json\` first. - -**FIRST decide adopt vs decompose:** -${namedParent ? `A parent is named: \`${namedParent}\`. Fetch it from the tracker (${p.trackerProvider}). If it ALREADY EXISTS and has child issues, **ADOPT — do NOT decompose or create anything**: list its children (Linear: \`list_issues parentId=${namedParent}\`), and return them verbatim as \`childrenJson\` = JSON array of {id, title, blockedBy:[...], status}. Derive \`blockedBy\` from the children's tracker relations, or (if none are set) from the stack order in the parent's description (each later child blocked by the first/foundational one). Set parentTitle from the parent, whyEpic from its description, and propose the integration branch name \`-\` (kebab). Skip steps 1-4 below. If the named parent does NOT exist yet, fall through to decompose.` : `No parent named — decompose the feature below into a new epic.`} - -Otherwise, decompose this feature into a parent + 3-8 children: -"${ctx.input.feature}" - -1. Read product/spec docs and explore the codebase (sourceScope) to see what exists; in a tracker, search for near-duplicate items first. -2. SANITY-CHECK it is genuinely epic-shaped — at least one child's runtime contract depends on another being only partially complete (the interleave test). If the items are independent, STOP and say it should be a /rig-sprint instead (return an empty children list and explain in whyEpic). -3. Create the parent (tracker: ${p.trackerProvider}) and each child with concrete, testable acceptance criteria, small enough for one agent session (1-3 files), foundational work first. -4. Record \`blockedBy\` for EVERY real dependency — this drives execution order. -5. Propose the integration branch name \`-\` (kebab). - -Return: parent (id or slug), parentTitle, integrationBranch, whyEpic, and childrenJson = a JSON array string of {id, title, blockedBy:[...], status}.`; - }} - - - {(d) => { - const pl = d["plan"]; - const p = d["epic-preflight"]; - return `You are executing the \`start\` step of the /rig-epic skill (${SKILL}). Print the intent banner first. - -1. \`git fetch origin\`; confirm the parent and >=1 child exist. -2. Cut the integration branch \`${pl.integrationBranch}\` from \`${p.baseRef}\` WITHOUT a local checkout, and non-destructively (leave it if it already exists): - git push origin ${p.baseRef}:refs/heads/${pl.integrationBranch} -3. Write \`.rig/epics/${pl.integrationBranch}.json\` with { parent, parentTitle, integrationBranch, whyEpic, children:[{id,title,blockedBy,branch:null,status:"todo"}] } using the plan's data: - parent=${pl.parent}; whyEpic=${JSON.stringify(pl.whyEpic)}; children=${pl.childrenJson} - Ensure \`.rig/epics/\` is in .gitignore. -4. In a tracker, add an "Integration branch: target \`${pl.integrationBranch}\`, not the trunk" note to each child, and ensure the PARENT is In Progress (adaptive: \`get_issue\`; if not already started, \`save_issue state="In Progress"\`). Children get their own In Progress from their rig-task Step 1. -5. Name the session "EPIC: ${pl.parentTitle} (${pl.parent})". - -Return integrationBranch, the stateFile path, and a summary. Do NOT start executing children — that is an explicit opt-in.`; - }} - - - ) : null} - - {/* ── front-loaded spec review of ALL children, then ONE approval ── */} - {specReviewReady ? ( - - - - {() => `Front-loaded ARCHITECT spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec from the tracker (${children.map((c) => c.id).join(", ")}) and review each for implementability AGAINST the integration branch (inspect it: \`git fetch origin\`; the terminal service + prior children live on \`${integrationBranch}\`). Flag ambiguities, missing acceptance criteria, and cross-child ordering issues. Return role="architect", notes (per child), and a \`blockers\` array of ONLY things that must be resolved before ANY coding starts — prefix each with the child id (e.g. "CEX-542: gap semantics undefined vs the shared sequencer …").`} - - - {() => `Front-loaded QA spec review for the epic on ${integrationBranch} (${SKILL}). ${cd} Fetch EVERY child's spec (${children.map((c) => c.id).join(", ")}) and review each from a testing perspective against the integration branch. Return role="qa", notes, and a \`blockers\` array of ONLY untestable/contradictory criteria that must be fixed before coding — prefix each with the child id.`} - - - {specReviewed && !specAnswered ? ( - advisor ? ( - // Autonomous arch gate: Fable decides proceed/direction in the human's - // place so a kicked-off epic never parks here. Same node id + schema as - // the HumanTask, so everything downstream (runApproved, per-child - // specNotes, the proceed=false halt) is unchanged. - - {() => `You are the ARCH ADVISOR for the epic on \`${integrationBranch}\` (${SKILL}). ${cd} You stand in for the human at the front-loaded spec gate: BEFORE ${children.length} parallel implementation runs (${children.map((c) => c.id).join(", ")}) commit against the integration branch, you decide whether to proceed and produce the steering direction every child's coder will follow. No human is waiting — you ARE the gate. - -Two independent front-loaded reviews just ran against \`${integrationBranch}\`: -- ARCHITECT — notes: ${JSON.stringify(specArch?.notes ?? "")} -- QA — notes: ${JSON.stringify(specQa?.notes ?? "")} -${ - specHasBlockers - ? `Flagged blocker(s) (${specBlockers.length}):\n- ${specBlockers.join("\n- ")}` - : "Neither reviewer flagged a blocker." -} - -Judge ADVERSARIALLY — a paused child fails the whole epic, and fanning ${children.length} runs out on a bad spec wastes real compute: -- Verify against the branch if useful (\`git fetch origin\`; the terminal service + any prior children live on \`${integrationBranch}\`). -- proceed=true ONLY if the specs are coherent, each child is well-scoped, and there is NO contradiction or missing decision that would make the parallel tasks diverge or need rework. Then set \`direction\` to concrete per-child guidance (prefix each with the child id, e.g. "CEX-543: funding line = rate+countdown") that resolves every flagged item — this is handed verbatim to each child's coder. -- proceed=false if there is a genuine blocker that must be resolved before ANY coding. Put the specific blocking reason(s) and what a human must decide into \`direction\`; the epic HALTS with that as its blocked report. - -Return JSON: {"proceed": , "direction": ""}`} - - ) : ( - c.id).join(", ")}) on \`${integrationBranch}\`.\n\n${ - specHasBlockers - ? `Found ${specBlockers.length} item(s) needing your direction:\n\n- ${specBlockers.join("\n- ")}\n\n` - : "No blockers found.\n\n" - }Type direction that will be handed to EVERY child's coder (address the items above — e.g. "CEX-543: funding line = rate+countdown", "CEX-546: risk panel against RiskFrameSchema fixtures"). Then set proceed=true to execute all children uninterrupted, or proceed=false to halt.\n\nAnswer with JSON, e.g.:\n{"proceed": true, "direction": ""}`} - /> - ) - ) : null} - - ) : null} - - {/* ── run: each child = rig-task against the integration branch, then a merge gate ── */} - {readyToRun - ? (() => { - const lanes: React.ReactElement[] = []; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (merged(child)) { - continue; // already merged (state file or a prior lane) — its lane is done - } - // Serialize each child behind the previous child's merge gate — but - // ONLY while that predecessor is still in-flight. Once it has merged, - // its lane (including its `merge-` node) is skipped above via - // `continue`, so a `dependsOn: ['merge-']` would point at an - // unmounted node → DEPENDENCY_DEADLOCK at every child boundary (#27). - // A merged predecessor means this child is already free to start. - const prevUnmerged = i > 0 && !merged(children[i - 1]); - const prevId = i > 0 ? children[i - 1].id : undefined; - lanes.push( - - - - {(d) => { - const run = d[`child-${child.id}`]; - return `You are the merge gate for epic child ${child.id} (${SKILL}). ${cd} - -The child's rig-task run finished with outcome "${run.outcome}" (PR ${run.prUrl}) and, if clean, squash-merged it into the integration branch (or armed \`--squash --auto\` if a required check was pending). - -ONLY "clean" is merge-green: -- "clean" → the child PR **squash-merges** into \`${integrationBranch}\` (directly when there are no required checks, else once they pass). Confirm/WAIT: poll \`gh pr view --json state\` (~60s intervals, up to ~30min) until state=MERGED, then \`git fetch origin\` and confirm the integration tip advanced. Then: (a) update \`.rig/epics/${integrationBranch}.json\` — mark ${child.id} status="merged" + record its branch/PR; (b) ensure the child ticket ${child.id} is Done (adaptive — ignore the githubIntegration config flag: \`get_issue\`; if not already Done, \`save_issue state="Done"\`; if the integration already closed it, leave it). Return merged=true, the PR url, a short detail. If it never merges (checks failing) → merged=false with why. -- anything else ("actionable"/"timeout"/"blocked") → the child is not clean and did NOT enable auto-merge. Return merged=false with a detail; the epic stops here for a human. - -Never force-push the integration branch (in-flight child PRs are based on its tip).`; - }} - - , - ); - } - return {lanes}; - })() - : null} - - {/* ── review: combined-diff, three lenses in parallel (the hard gate before finish) ── */} - {reviewReady ? ( - - - - {() => `Lens 1 — SIMPLIFICATION (/rig-epic review, ${SKILL}). ${cd} Ensure an integration-branch worktree, then diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\` and list merged child PRs (\`gh pr list --base ${integrationBranch} --state merged\`). - -Find abstractions to collapse, helpers one PR added that another PR's final shape made redundant, config knobs nobody sets, code paths the combined diff made dead, one-caller types. Concrete deletions/merges with file:line, highest-impact first. Skip correctness. Return lens="simplify", counts p0p1/p2, and findings.`} - - - {() => `Lens 2 — CROSS-PR CORRECTNESS (/rig-epic review, ${SKILL}). ${cd} Walk the review-pattern catalog (.claude/REVIEWER.md) against the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`. - -Per-PR review already ran; catch interactions only visible at the merged shape (PR-A's helper vs PR-D's stale caller; PR-B removed a knob PR-F still reads). Return lens="crosspr", counts p0p1/p2, and findings with file:line + category.`} - - - {() => `Lens 3 — DEAD CODE & STALE REFS (/rig-epic review, ${SKILL}). ${cd} For the COMBINED diff \`git diff ${pre?.baseRef ?? "origin/main"}...HEAD\`: for every symbol added, is it called elsewhere? For every symbol removed, grep the whole tree (workflows, manifests, IaC, scripts, docs) for residual refs. Return lens="deadcode", counts p0p1/p2, and findings with file:line.`} - - - - {() => `Consolidate the three review lenses for the epic on ${integrationBranch} (${SKILL}). Read each lens's output row, dedupe, and produce ONE P0/P1/P2 list grouped by lens with counts. Set clean=true only when there are zero P0/P1. Return p0p1, p2, clean, and the grouped report.`} - - {consolidated && !consolidated.clean ? ( - - ) : null} - {consolidated && !consolidated.clean && reviewApproved ? ( - - {(d) => `Apply the combined-diff review fixes on ${integrationBranch} (/rig-review fix --source local, ${SKILL}). ${cd} Fix the P0/P1 items, keep tests green, commit, and \`git push origin ${integrationBranch}\`. - -Findings: -${d["review-consolidate"].report} - -Return a summary of what you changed.`} - - ) : null} - - ) : null} - - {/* ── full-only gate before the squash-to-trunk PR ── */} - {phase === "full" && reviewReady && finishGatePassed ? ( - - ) : null} - - {/* ── finish: squash the integration branch into one PR to the trunk ── */} - {finishReady ? ( - - {(d) => { - const p = d["epic-preflight"]; - return `You are executing \`finish\` of /rig-epic (${SKILL}). ${cd} Print the intent banner first. The review gate is a HARD precondition and has passed. - -1. \`git fetch origin\`; if the trunk (${p.baseRef}) moved past \`${integrationBranch}\`, rebase the integration branch onto it (\`git rebase ${p.baseRef}\` on a local copy, then \`git push --force-with-lease origin :${integrationBranch}\`). -2. Open the final squash PR to \`${p.defaultBranch}\` **NON-draft** with a title referencing the parent and a body summarizing all children. Include the closes-verb (\`Fixes \` / \`Closes #\`) so the parent auto-closes. Then run \`gh pr ready \` to POST it as ready-for-review (never leave it a draft). -3. Merge behavior: ${merge ? "run `gh pr merge --squash --delete-branch --auto` so CI gates the squash-merge to the trunk; if protectedBranchMergeQueue, use `gh pr merge --auto` with no method flag (the queue decides)." : "STOP at the open, ready PR — squashing to the trunk is the human gate. Do NOT merge."} -4. Parent Done (adaptive — do NOT trust the githubIntegration flag): ${merge ? "you are squash-merging, so once the PR is MERGED, ensure the parent is Done — `get_issue`; if not already Done, `save_issue state=\"Done\"`; the closes-verb also handles it if an integration is live (don't clobber)." : "you are stopping at the open PR, so leave the parent In Progress — the parent moves to Done when a human merges the squash PR (its `Fixes ` closes it if the integration is live; otherwise a follow-up run reconciles it)."} Children were already set Done as they merged. -5. Delete the epic state file \`.rig/epics/${integrationBranch}.json\` once the work is ${merge ? "on the trunk" : "in its final PR"}. - -Return the PR number and url.`; - }} - - ) : null} - - {/* ── final report ── */} - - {() => { - const sq = ctx.outputMaybe(outputs.squashPr, { nodeId: "finish-squash" }); - const st = ctx.outputMaybe(outputs.start, { nodeId: "start" }); - const branch = integrationBranch || pre?.integrationBranch || ""; - let summary: string; - if (doFinish && sq) summary = `Epic ${branch}: squash PR ${sq.url} ${merge ? "squash-merged to trunk" : "open for human merge"}.`; - else if (doReview && consolidated) summary = `Epic ${branch}: combined review ${consolidated.clean ? "clean" : `${consolidated.p0p1} P0/P1`} — ready for finish.`; - else if (doRun && children.length) summary = `Epic ${branch}: ${children.filter(merged).length}/${children.length} children merged into the integration branch.`; - else if (doPlan && st) summary = `Epic started on ${branch}: ${children.length} children planned. Next: rig-epic run (or full).`; - else summary = pre?.banner ?? "rig-epic"; - return { phase, integrationBranch: branch, prUrl: sq?.url ?? "", summary }; - }} - + - ); -}, { output: outputs.epicResult }); + ), + { output: outputs.epicResult }, +); diff --git a/smithers/workflows/rig-task.tsx b/smithers/workflows/rig-task.tsx index d2c869f..17d3211 100644 --- a/smithers/workflows/rig-task.tsx +++ b/smithers/workflows/rig-task.tsx @@ -1,509 +1,30 @@ // smithers-source: seeded // smithers-metadata-version: 1 // smithers-display-name: rig-task — implement one unit end-to-end -// smithers-description: Canonicalizes the /rig-task skill as a durable graph — load spec, spec review, TDD (RED -> GREEN -> REFACTOR), pre-PR self-review gate, open PR, then the review-bot loop. Never auto-merges. +// smithers-description: Canonicalizes the /rig-task skill as a durable graph — load spec, spec review, TDD (RED -> GREEN -> REFACTOR), pre-PR self-review gate, open PR, then the review-bot loop. Never auto-merges. Thin wrapper over the composable TaskFlow fragment. // smithers-tags: rig, implement, tdd, review /** @jsxImportSource smithers-orchestrator */ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { createSmithers } from "smithers-orchestrator"; -import { z } from "zod/v4"; -import { providers } from "../agents"; +import { createSmithers, UI } from "smithers-orchestrator"; +import { TaskFlow, taskSchemas, taskBag, taskInputSchema, taskResultSchema } from "./flows/task-flow"; /** - * Role → model, matched to the user's own agent specs in .claude/agents/rig-*.md - * (rig-architect/rig-reviewer = opus, rig-qa/rig-coder = sonnet). Single Claude - * model per role — deliberately NOT the codex/fable-leading pools in agents.ts, - * which are more aggressive than the rig specs. `coord` covers orchestration - * steps (worktree, git/gh, tracker) that have no rig role → conservative sonnet. + * Standalone rig-task = the TaskFlow fragment under its own . The graph + * lives in flows/task-flow.tsx so rig-crank / EpicFlow can render it INLINE (one run, + * native deps, full time-travel) instead of a childRun . Registering + * TASK_TABLES here is what makes `outputs` carry the fragment's tables. */ -const ROLE = { - architect: providers.claudeOpus, // rig-architect → opus - reviewer: providers.claudeOpus, // rig-reviewer → opus - qa: providers.claudeSonnet, // rig-qa → sonnet - coder: providers.claudeSonnet, // rig-coder → sonnet - coord: providers.claudeSonnet, // orchestration/coordination (no rig spec) -} as const; - -/** - * Canonical graph of the /rig-task skill (.claude/skills/rig-task/SKILL.md). - * - * Smithers owns the deterministic control flow + durability; each node is a - * coding agent that executes one step of the skill against the live repo - * (worktree, git, gh, the Linear MCP, `.rig/config.json`). The skill file is - * the per-node spec — prompts point back at it so the two never drift. - * - * preflight -> setup -> [spec-architect | spec-qa] -> (approve-spec?) - * -> RED -> GREEN(loop x3) -> REFACTOR - * -> self-review(loop) -> open-PR (start phase, steps 1-5) - * -> review-bot loop -> result (finish phase, steps 6-7) - */ - -const inputSchema = z.object({ - target: z - .string() - .default("") - .describe('Ticket id (e.g. "CEX-123") OR a quoted ad-hoc "". Empty = infer from the current branch.'), - phase: z - .enum(["start", "finish", "both"]) - .default("both") - .describe("start = steps 1-5 (up to an open PR); finish = steps 6-7 (drive review to clean); both = one continuous run."), - base: z - .string() - .default("") - .describe("Stacked base ref to branch from and target the PR at, instead of vcs.baseRef (e.g. an epic integration branch). Empty = config default."), - local: z - .boolean() - .default(false) - .describe("Force the local /rig-review fix loop in the finish phase even when a cloud auto-fix workflow is enabled."), - autoMerge: z - .boolean() - .default(false) - .describe("Enable auto-merge: after the self-review is clean, squash-merge the PR (`gh pr merge --squash --auto`) so it lands once any required checks pass — or directly if there are none. Children always squash-merge; a configured merge queue wins. Default false → never auto-merges."), - specGate: z - .boolean() - .default(true) - .describe("When false, suppress the pre-coding spec-review APPROVAL gate (spec review still runs, non-blocking). /rig-epic sets this false for children — it front-loads ONE spec approval for the whole epic, and a child that paused mid-run would fail the parent Subflow."), - specNotes: z - .string() - .default("") - .describe("Free-form direction from the caller (e.g. /rig-epic's front-loaded spec gate) resolving spec ambiguities. Folded into the spec the coder works from."), - prNumber: z - .number() - .int() - .optional() - .describe("Open PR number to resume from when phase=finish is run on its own."), -}); - -const outputSchema = z.object({ - outcome: z.string().describe('One of: "pr-open", "clean", "actionable", "timeout", "blocked".'), - unit: z.string(), - prUrl: z.string(), - testsGreen: z.boolean(), - reviewState: z.string(), - summary: z.string(), -}); - -const { Workflow, Sequence, Parallel, Task, Loop, Branch, Approval, UI, smithers, outputs } = createSmithers({ - input: inputSchema, - result: outputSchema, - preflight: z.object({ - unit: z.string(), - isAdHoc: z.boolean(), - baseRef: z.string(), - testCommand: z.string(), - trackerProvider: z.string(), - ticketPrefix: z.string(), - reviewBot: z.string(), - maxRounds: z.number().int(), - defaultBranch: z.string(), - summary: z.string(), - }), - setup: z.object({ - worktreePath: z.string(), - branch: z.string(), - specTitle: z.string(), - specDescription: z.string(), - acceptanceCriteria: z.string(), - trackerState: z.string(), - epicChildMismatch: z.boolean().describe("True if this ticket is an epic child but base is the trunk — block before spending agents."), - suggestedBase: z.string().describe("The integration branch this epic child should stack on (when epicChildMismatch)."), - }), - specArchitect: z.object({ notes: z.string(), blockers: z.array(z.string()) }), - specQa: z.object({ testPlan: z.string(), blockers: z.array(z.string()) }), - specApproval: z.object({ approved: z.boolean() }), - red: z.object({ redVerified: z.boolean(), testOutput: z.string(), summary: z.string() }), - green: z.object({ green: z.boolean(), testOutput: z.string(), summary: z.string() }), - refactor: z.object({ changed: z.boolean(), summary: z.string() }), - reviewFind: z.object({ - p0p1: z.number().int(), - p2: z.number().int(), - p3: z.number().int(), - clean: z.boolean(), - findings: z.string(), - }), - reviewFix: z.object({ summary: z.string() }), - pr: z.object({ number: z.number().int(), url: z.string(), title: z.string() }), - blocked: z.object({ stage: z.string(), reason: z.string(), detail: z.string() }), - reviewBot: z.object({ outcome: z.string(), detail: z.string() }), +const { Workflow, smithers, outputs } = createSmithers({ + input: taskInputSchema, + result: taskResultSchema, + ...taskSchemas(), }); -const SKILL = ".claude/skills/rig-task/SKILL.md"; -const cd = (wt: string) => `Work in the worktree \`${wt}\` — start every shell command with \`cd "${wt}" &&\`.`; - -export default smithers((ctx) => { - const { phase } = ctx.input; - const runStart = phase === "start" || phase === "both"; - const runFinish = phase === "finish" || phase === "both"; - - // Gate values, read from prior node outputs (undefined until they run). - const pre = ctx.outputMaybe(outputs.preflight, { nodeId: "preflight" }); - const setup = ctx.outputMaybe(outputs.setup, { nodeId: "setup" }); - const arch = ctx.outputMaybe(outputs.specArchitect, { nodeId: "spec-architect" }); - const qa = ctx.outputMaybe(outputs.specQa, { nodeId: "spec-qa" }); - const specBlockers = [...(arch?.blockers ?? []), ...(qa?.blockers ?? [])]; - // The spec-review approval only gates when specGate is on. /rig-epic passes - // specGate:false (it front-loads one epic-level spec approval) so a child never - // pauses mid-run — a paused Subflow child fails the parent epic. - const specGate = ctx.input.specGate !== false; - const specHasBlockers = specBlockers.length > 0 && specGate; - - const greenRow = ctx.latest(outputs.green, "green-step"); - const green = greenRow?.green === true; - - const reviewRow = ctx.latest(outputs.reviewFind, "review-find"); - const reviewHasP0P1 = (reviewRow?.p0p1 ?? 0) > 0; - const reviewClean = reviewRow?.clean === true; - const maxRounds = pre?.maxRounds ?? 5; - - // Auto-merge mode: after self-review is clean, SQUASH-merge the PR via - // `gh pr merge --squash --auto` (required checks gate it; merges directly if - // there are none). Always squash — never rebase. autoMerge=false → never merges. - const autoMerge = ctx.input.autoMerge === true; - // Early base guard: this ticket is an epic child but base is the trunk. - const specMismatch = setup?.epicChildMismatch === true; - - const openPr = ctx.outputMaybe(outputs.pr, { nodeId: "open-pr" }); - const blockedReview = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-review" }); - const blockedGreen = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-green" }); - const blockedBase = ctx.outputMaybe(outputs.blocked, { nodeId: "blocked-base" }); - const startTerminal = !runStart || Boolean(openPr || blockedReview || blockedGreen || blockedBase); - - const prNumber = openPr?.number ?? ctx.input.prNumber; - const canFinish = runFinish && prNumber != null; - const reviewBotRow = ctx.outputMaybe(outputs.reviewBot, { nodeId: "review-bot" }); - const finishTerminal = !canFinish || Boolean(reviewBotRow); - - const showResult = startTerminal && finishTerminal; - - return ( +export default smithers( + (ctx) => ( - - {/* ── Step 0: resolve the unit + config from .rig/config.json ── */} - - {async () => { - const def = { - baseRef: "origin/main", - testCommand: "npm test", - trackerProvider: "none", - ticketPrefix: "", - reviewBot: "none", - maxRounds: 5, - defaultBranch: "main", - }; - let cfg: any = {}; - try { - cfg = JSON.parse(readFileSync(resolve(process.cwd(), ".rig/config.json"), "utf8")); - } catch { - /* unconfigured fallback — tracker none, npm test, origin/main */ - } - const ticketPrefix = cfg?.tracker?.ticketPrefix ?? def.ticketPrefix; - const provider = cfg?.tracker?.provider ?? def.trackerProvider; - const target = ctx.input.target.trim(); - const looksLikeTicket = ticketPrefix && new RegExp(`^${ticketPrefix}\\d+$`, "i").test(target); - const isAdHoc = provider === "none" || (target !== "" && !looksLikeTicket); - const baseRef = ctx.input.base.trim() || cfg?.vcs?.baseRef || def.baseRef; - const unit = target || "(infer from branch)"; - return { - unit, - isAdHoc, - baseRef, - testCommand: cfg?.test?.command ?? def.testCommand, - trackerProvider: provider, - ticketPrefix, - reviewBot: cfg?.review?.bot ?? def.reviewBot, - maxRounds: cfg?.review?.maxRounds ?? def.maxRounds, - defaultBranch: cfg?.vcs?.defaultBranch ?? def.defaultBranch, - summary: `rig-task ${ctx.input.phase}: ${isAdHoc ? `ad-hoc "${unit}"` : unit} → base ${baseRef}, tests \`${cfg?.test?.command ?? def.testCommand}\`, bot ${cfg?.review?.bot ?? def.reviewBot}`, - }; - }} - - - {/* ── START phase: steps 1-5 ── */} - {runStart ? ( - - {/* Step 1 — load spec + set up an isolated worktree */} - - {(d) => { - const p = d["preflight"]; - return `You are executing Step 1 of the /rig-task skill. Read ${SKILL} (Step 1) and \`.rig/config.json\` first, then: - -0. BASE GUARD — do this BEFORE creating any worktree. Determine whether this ticket is an epic CHILD: its tracker parent is an epic, or an integration branch \`-*\` for its parent already exists on origin (\`git ls-remote --heads origin\`). ${ctx.input.base ? `A stacked base (\`${ctx.input.base}\`) was provided, so a child is fine — set epicChildMismatch=false.` : "NO stacked base was provided (base is the trunk)."} If it IS an epic child AND no stacked base was provided, STOP immediately: set epicChildMismatch=true, suggestedBase=, do NOT create a worktree, leave the spec fields empty, and return. Otherwise set epicChildMismatch=false and suggestedBase="". - -1. Load the spec for: ${p.isAdHoc ? `the ad-hoc task "${ctx.input.target}"` : `ticket ${ctx.input.target || "(infer from the current branch)"}`}. - - Tracker "${p.trackerProvider}": fetch the issue (Linear MCP get_issue / \`gh issue view\`) and set it to "In Progress" now (idempotent; do this even under githubIntegration). Ad-hoc: the description IS the spec — if it's a one-liner, expand acceptance criteria. -2. Set up an isolated checkout via the /rig-worktree skill (do NOT inline \`git worktree add\`). Branch from \`${p.baseRef}\`${ctx.input.base ? " (a STACKED base — target the PR at it, not the trunk)" : ""}. Use the tracker's suggested gitBranchName verbatim when present, else vcs.branchConvention. Name the session "FEAT:"/"CHORE:" with --skip-if-prefix "EPIC:". - -CALLER DIRECTION — treat as authoritative spec resolution and **fold it into the specDescription AND acceptanceCriteria you return** (downstream RED/GREEN read those), applying only the parts relevant to THIS unit: -${ctx.input.specNotes?.trim() ? ctx.input.specNotes : "(none)"} - -Restate the acceptance criteria (with the direction folded in) back to yourself before finishing. Return the absolute worktree path, the branch name, the spec title/description, the acceptance criteria (as text), the tracker state you set, epicChildMismatch, and suggestedBase.`; - }} - - - {/* Base guard: epic child on the trunk — stop before spending any review/TDD agents. */} - {setup && specMismatch ? ( - - {(d) => ({ - stage: "base", - reason: "Epic child launched against the trunk — it must stack on the integration branch", - detail: `Re-run with base=${d["setup"].suggestedBase || ""} (or drive it via rig-epic). No worktree created; no review/TDD agents spent.`, - })} - - ) : null} - - {/* Everything past setup runs only when the base is right. */} - {setup && !specMismatch ? ( - - {/* Step 2 — spec review: architect + qa in parallel */} - - - {(d) => { - const s = d["setup"]; - return `You are the ARCHITECT reviewing a spec for implementability (Step 2 of ${SKILL}). ${cd(s.worktreePath)} - -Spec: -${s.specTitle} - -${s.specDescription} - -Acceptance criteria: -${s.acceptanceCriteria} - -Identify ambiguities, missing acceptance criteria, the files that must change, and a suggested implementation order. Return your notes, and a \`blockers\` array listing ONLY things that must be resolved BEFORE coding can start (empty if none).`; - }} - - - {(d) => { - const s = d["setup"]; - return `You are QA reviewing a spec from a testing perspective (Step 2 of ${SKILL}). ${cd(s.worktreePath)} - -Spec: -${s.specTitle} - -${s.specDescription} - -Acceptance criteria: -${s.acceptanceCriteria} - -What test cases are needed? Are the acceptance criteria testable? What edge cases matter? Return a \`testPlan\`, and a \`blockers\` array of ONLY criteria that are untestable/contradictory and must be fixed before coding (empty if none).`; - }} - - - - {/* Spec-review gate: pause for a human only when a blocker was flagged. */} - {specHasBlockers ? ( - - ) : null} - - {/* Step 3 — RED: failing tests first */} - - {(d) => { - const s = d["setup"]; - return `You are executing Step 3 (RED) of ${SKILL}. ${cd(s.worktreePath)} - -Write tests for this unit BEFORE any implementation. Cover every acceptance criterion plus the edge cases the architect flagged. Do NOT stub or comment out — the tests must compile and FAIL for the right reason (missing implementation), not a syntax error. Match the project's test framework and colocation conventions. - -Spec: -${s.specTitle} -${s.specDescription} - -Acceptance criteria: -${s.acceptanceCriteria} - -Architect notes: -${d["spec-architect"].notes} - -QA test plan: -${d["spec-qa"].testPlan} - -Run \`${(ctx.outputMaybe(outputs.preflight, { nodeId: "preflight" })?.testCommand) ?? "the test command"}\` from the worktree. VERIFY RED: each new test must fail with a message reflecting the missing behavior. A new test that passes immediately was pinning existing behavior — rewrite it. Return redVerified=true only when the suite fails for the right reason, plus the test output and a short summary.`; - }} - - - {/* Step 4 — GREEN: minimum implementation, up to 3 iterations */} - - - {(d) => { - const s = d["setup"]; - const prev = ctx.latest(outputs.green, "green-step"); - return `You are executing Step 4 (GREEN) of ${SKILL}. ${cd(s.worktreePath)} - -Make the failing tests pass with the MINIMUM change — no features not required by a test. Explore the affected files first and prefer EXTENDING or REUSING existing code over a parallel implementation. - -Spec: -${s.specTitle} -${s.specDescription} - -Failing tests (from RED): -${d["red-step"].testOutput} -${prev ? `\nPrevious GREEN attempt still failing with:\n${prev.testOutput}\n(fix these remaining failures)` : ""} - -Re-run the full test suite from the worktree. If implementing exposes a missing edge case, add that test first rather than piling untested behavior in. Return green=true only when the whole suite passes, plus the test output and a summary of what you changed.`; - }} - - - - - {/* Step 4.25 — REFACTOR (only while green) */} - - {(d) => { - const s = d["setup"]; - return `You are executing Step 4.25 (REFACTOR) of ${SKILL}. ${cd(s.worktreePath)} - -The implementation is GREEN. If — and only if — there is obvious duplication, awkward naming, or a helper that wants extracting, make that cleanup with NO new behavior, then re-run the full suite to confirm it stays green. If the code is already clean, change nothing. Return changed (true/false) and a one-line summary.`; - }} - - - {/* Step 4.5 — pre-PR self-review gate (find -> fix -> re-find) */} - - - - {(d) => { - const s = d["setup"]; - return `You are executing Step 4.5 (pre-PR self-review) of ${SKILL} — the FIND half. ${cd(s.worktreePath)} - -Run the /rig-review skill for this unit: walk \`${d["preflight"] ? ".claude/REVIEWER.md" : "the review patterns file"}\` against \`git diff ${d["preflight"].baseRef}...HEAD\` and return a triaged P0-P3 list. Count findings by severity: p0p1 (must-fix before merge), p2, p3. Set clean=true only when there are zero P0/P1. Return the findings text too.`; - }} - - - {(d) => { - const s = d["setup"]; - return `You are executing Step 4.5 of ${SKILL} — the FIX half (/rig-review fix, local). ${cd(s.worktreePath)} - -Fix every P0/P1 finding below, keeping the test suite green. Do NOT touch P2/P3 (they ship as follow-ups). - -Findings: -${d["review-find"].findings} - -Return a short summary of what you changed. The loop will re-run the review to confirm convergence.`; - }} - - } - else={null} - /> - - - - {/* Step 5 — push + open the PR, only when the self-review is clean */} - - {(d) => { - const s = d["setup"]; - const p = d["preflight"]; - return `You are executing Step 5 of ${SKILL}. ${cd(s.worktreePath)} - -1. Commit all changes with a message referencing the unit. -2. Push the branch. -3. Open a PR with \`gh pr create\` targeting \`${ctx.input.base || p.defaultBranch}\`: - - Title carries the ticket id where a tracker is used, e.g. \`feat(${ctx.input.target || "SCOPE"}): …\`. - - Body: a summary; the tracker link (\`Fixes \` for Linear / \`Closes #\` for GitHub); a test plan; and an \`## Architecture\` section stating any new abstraction/package/dependency/migration or "No architectural change." and WHY existing code wasn't reused. -4. TRACKER LINK + TRANSITION (adaptive — works with OR without a live Linear↔GitHub integration; do NOT trust the githubIntegration config flag, check reality): - - Ensure the PR is linked to the issue: \`get_issue\`; if no attachment already references this PR URL (the integration may have added one), \`create_attachment\` with the PR URL. - - Ensure the issue is In Review: \`get_issue\`; if it is NOT already In Review or further along (Done), \`save_issue state="In Review"\`. If the integration already advanced it, leave it — never clobber a further-along state. -${autoMerge - ? `5. AUTO-MERGE (enabled), self-review is clean: land this PR with **squash** (\`gh pr merge --squash --delete-branch\`, targeting \`${ctx.input.base || p.defaultBranch}\`). If any required check is pending, arm it CI-gated instead: add \`--auto\`. If there are NO required checks (auto-merge can't be armed / the PR is already mergeable), merge directly with the same command minus \`--auto\`. (If \`vcs.protectedBranchMergeQueue\` is true, use \`--auto\` with NO method flag — the queue decides.) Always squash; never rebase. Don't merge manually beyond this.` - : `5. Do NOT \`gh pr merge\` — this run does not auto-merge; the PR waits for a human.`} - -Return the PR number, URL, and title.`; - }} - - } - else={ - - {(d) => ({ - stage: "self-review", - reason: "P0/P1 findings unresolved after the max review rounds", - detail: d["review-find"].findings, - })} - - } - /> - - } - else={ - - {() => { - const g = ctx.latest(outputs.green, "green-step"); - return { - stage: "green", - reason: "Tests still failing after 3 GREEN iterations — spec likely wrong or an unstated constraint", - detail: g?.testOutput ?? "(no test output captured)", - }; - }} - - } - /> - - ) : null} - - ) : null} - - {/* ── FINISH phase: step 6 (review-bot loop) ── */} - {canFinish ? ( - - {(d) => { - const p = d["preflight"]; - const wt = setup?.worktreePath; - return `You are executing Step 6 (review-bot loop) of ${SKILL} for PR #${prNumber}. ${wt ? cd(wt) : "First re-establish context: resolve the open PR's worktree from PR #" + prNumber + " / the current branch and cd into it."} - -Review bot: \`${p.reviewBot}\`. ${ctx.input.local ? "The --local flag is set: force the local /rig-review fix loop." : ""} -- reviewBot "none" → nothing to drive; the Step 4.5 local gate was the whole review. outcome="clean". -- A cloud auto-fix workflow is enabled and --local was NOT passed → WATCH only (do not fix or push). Poll the PR (~60s, up to ~30min) until the bot's review reaches a terminal state; report it. -- Otherwise → DRIVE via the /rig-review fix loop: poll + classify the bot's review, fix via a coding agent, commit, push, re-trigger (\`${p.reviewBot === "bugbot" ? "bugbot run" : "the configured retrigger"}\`), up to ${p.maxRounds} rounds. - -Return outcome as exactly one of: "clean" (no actionable issues — merge gates take over), "actionable" (feedback remains after the last round), or "timeout" (bot didn't respond). Do NOT merge. Include a short detail string.`; - }} - - ) : null} - - {/* ── Step 7 — hand back (report only; never merges) ── */} - {showResult ? ( - - {() => { - const bot = ctx.outputMaybe(outputs.reviewBot, { nodeId: "review-bot" }); - const g = ctx.latest(outputs.green, "green-step"); - const rev = ctx.latest(outputs.reviewFind, "review-find"); - const blk = blockedBase ?? blockedGreen ?? blockedReview; - let outcome = "pr-open"; - if (blk) outcome = "blocked"; - else if (bot) outcome = bot.outcome; - const testsGreen = g?.green === true; - const reviewState = rev ? `${rev.p0p1} P0/P1 + ${rev.p2} P2 + ${rev.p3} P3` : "n/a"; - const unit = pre?.unit ?? ctx.input.target; - const prUrl = openPr?.url ?? ""; - const mergeNote = autoMerge ? "squash-merge enabled (CI-gated if checks exist, else direct)" : "not merged (waits for a human)"; - const summary = blk - ? `BLOCKED at ${blk.stage}: ${blk.reason}.${blk.stage === "base" ? "" : " No PR opened."}` - : bot - ? `PR ${prUrl} — review-bot outcome: ${bot.outcome}. ${bot.outcome === "clean" ? `Merge gate: ${mergeNote}.` : "Left for a human."}` - : `PR opened: ${prUrl}. tests: ${testsGreen ? "green" : "red"}, review: ${reviewState}. ${mergeNote}.`; - return { outcome, unit, prUrl, testsGreen, reviewState, summary }; - }} - - ) : null} + - ); -}, { output: outputs.result }); + ), + { output: outputs.result }, +); From c1da2753dd3dc6ceb0f481285a9b6dc2b8a12a27 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:59:29 +0000 Subject: [PATCH 05/18] =?UTF-8?q?fix(smithers):=20clear=20the=2010=20typec?= =?UTF-8?q?heck=20findings=20=E2=80=94=20CI=20now=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves every error the new typecheck job surfaced. All fixes preserve runtime behavior; none weaken the check. - rig-crank.tsx: remove duplicate `result: resultSchema` key (TS1117 — a copy-paste leftover; `result` is already registered with `input` above). Narrow `pick` before use (Boolean(pick) && … doesn't narrow → guard inside Boolean(…)). - rig-epic.tsx: read the dynamic-registry keys childRun/epicResult through a `Record` alias. These are registered at runtime via epicSchemas()/taskSchemas() (typed Record), so they're spread- only and not statically visible on `outputs` — same treatment epicBag()/ taskBag() already give their `outputs` param. NOT a runtime bug. - epic-flow.tsx: annotate the 5 render-prop params `(d)` -> `(d: any)`, matching task-flow.tsx's existing convention (smithers' doesn't infer `d` from `deps`). Verified locally: tsc --noEmit exits 0; scripts/ suite 26/26. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- smithers/workflows/flows/epic-flow.tsx | 10 +++++----- smithers/workflows/rig-crank.tsx | 3 +-- smithers/workflows/rig-epic.tsx | 11 +++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/smithers/workflows/flows/epic-flow.tsx b/smithers/workflows/flows/epic-flow.tsx index fe4e8d6..a2bc297 100644 --- a/smithers/workflows/flows/epic-flow.tsx +++ b/smithers/workflows/flows/epic-flow.tsx @@ -333,7 +333,7 @@ export function EpicFlow({ input, ctx, tables, childTables }: EpicFlowProps) { {doPlan ? ( - {(d) => { + {(d: any) => { const p = d["epic-preflight"]; const namedParent = (input.parent || p.parent || "").trim(); return `You are executing the \`plan\` step of the /rig-epic skill. Read ${SKILL} and \`.rig/config.json\` first. @@ -354,7 +354,7 @@ Return: parent (id or slug), parentTitle, integrationBranch, whyEpic, and childr }} - {(d) => { + {(d: any) => { const pl = d["plan"]; const p = d["epic-preflight"]; return `You are executing the \`start\` step of the /rig-epic skill (${SKILL}). Print the intent banner first. @@ -474,7 +474,7 @@ Return JSON: {"proceed": , "direction": " - {(d) => { + {(d: any) => { const run = d[`child-${child.id}-result`]; return `You are the merge gate for epic child ${child.id} (${SKILL}). ${cd} @@ -528,7 +528,7 @@ Per-PR review already ran; catch interactions only visible at the merged shape ( ) : null} {consolidated && !consolidated.clean && reviewApproved ? ( - {(d) => `Apply the combined-diff review fixes on ${integrationBranch} (/rig-review fix --source local, ${SKILL}). ${cd} Fix the P0/P1 items, keep tests green, commit, and \`git push origin ${integrationBranch}\`. + {(d: any) => `Apply the combined-diff review fixes on ${integrationBranch} (/rig-review fix --source local, ${SKILL}). ${cd} Fix the P0/P1 items, keep tests green, commit, and \`git push origin ${integrationBranch}\`. Findings: ${d["review-consolidate"].report} @@ -555,7 +555,7 @@ Return a summary of what you changed.`} {/* ── finish: squash the integration branch into one PR to the trunk ── */} {finishReady ? ( - {(d) => { + {(d: any) => { const p = d["epic-preflight"]; return `You are executing \`finish\` of /rig-epic (${SKILL}). ${cd} Print the intent banner first. The review gate is a HARD precondition and has passed. diff --git a/smithers/workflows/rig-crank.tsx b/smithers/workflows/rig-crank.tsx index 04cd6fd..50e178a 100644 --- a/smithers/workflows/rig-crank.tsx +++ b/smithers/workflows/rig-crank.tsx @@ -61,7 +61,6 @@ const { Workflow, Sequence, Parallel, Task, Loop, Branch, smithers, outputs } = probe: z.object({ lens: z.string(), riskFound: z.boolean(), evidence: z.string() }), // DeriskLoop-pattern risk probes verify: z.object({ green: z.boolean(), kind: z.string(), evidence: z.string() }), // kind: unit | e2e | risk | infra land: z.object({ ticketId: z.string(), merged: z.boolean(), detail: z.string() }), - result: resultSchema, }); export default smithers((ctx) => { @@ -69,7 +68,7 @@ export default smithers((ctx) => { const seed = ctx.input.built.join(", ") || "(none)"; const pick = ctx.latest(outputs.pick, "pick"); - const backlogDry = Boolean(pick) && (pick.ready === false || (pick.ticketId ?? "") === ""); + const backlogDry = Boolean(pick && (pick.ready === false || (pick.ticketId ?? "") === "")); const verify = ctx.latest(outputs.verify, "verify"); const verifyGreen = verify?.green === true; diff --git a/smithers/workflows/rig-epic.tsx b/smithers/workflows/rig-epic.tsx index d782e8c..44f5fde 100644 --- a/smithers/workflows/rig-epic.tsx +++ b/smithers/workflows/rig-epic.tsx @@ -21,6 +21,13 @@ const { Workflow, smithers, outputs } = createSmithers({ ...taskSchemas("child"), }); +// `outputs` is the dynamic schema registry. epicSchemas()/taskSchemas() register +// their tables typed as Record, so spread-only keys (childRun, +// epicResult) exist at runtime but aren't statically visible on `outputs`. Read +// them through the registry type — exactly as epicBag()/taskBag() already type +// their `outputs` parameter. +const reg = outputs as Record; + export default smithers( (ctx) => ( @@ -29,9 +36,9 @@ export default smithers( input={ctx.input} ctx={ctx} tables={epicBag(outputs)} - childTables={taskBag(outputs, outputs.childRun, "child")} + childTables={taskBag(outputs, reg.childRun, "child")} /> ), - { output: outputs.epicResult }, + { output: reg.epicResult }, ); From ad3625ab1a7afc85549b1aa4d3f217a93c436d0f Mon Sep 17 00:00:00 2001 From: Paul Gebheim Date: Tue, 28 Jul 2026 02:40:00 -0700 Subject: [PATCH 06/18] refactor(smithers): rename rig-crank workflow to rig-loop (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'crank' was obscure; 'loop' names what it is — the autonomous backlog-draining build loop (advisor-picks the next ready ticket, builds via rig-task, verifies with backpressure, lands, repeats until the backlog is dry). Chosen over rig-run (collides with smithers' up/run verbs) and rig-watch (implies passive monitoring; this actively builds/lands). - smithers/workflows/rig-crank.tsx -> rig-loop.tsx (git rename) - , Loop node id, header/display-name/description updated - all cross-references in README + sibling workflows/flows updated - metaphor uses of 'the crank' reworded so nothing orphaned remains install.sh needs no change (vendors via the rig-*.tsx glob). Verified: tsc --noEmit exits 0; scripts/ suite 26/26. Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM Co-authored-by: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- smithers/README.md | 4 ++-- smithers/workflows/flows/epic-flow.tsx | 6 +++--- smithers/workflows/flows/task-flow.tsx | 6 +++--- smithers/workflows/rig-delegation-spike.tsx | 4 ++-- smithers/workflows/rig-epic.tsx | 2 +- .../workflows/{rig-crank.tsx => rig-loop.tsx} | 20 +++++++++---------- smithers/workflows/rig-task.tsx | 2 +- 7 files changed, 22 insertions(+), 22 deletions(-) rename smithers/workflows/{rig-crank.tsx => rig-loop.tsx} (92%) diff --git a/smithers/README.md b/smithers/README.md index 2b74857..d9f63d4 100644 --- a/smithers/README.md +++ b/smithers/README.md @@ -18,7 +18,7 @@ Smithers run *executes*. The graph surface can **enforce** what prose can only | `workflows/flows/epic-flow.tsx` | **`EpicFlow`** — the integration-branch epic graph as a fragment: preflight → plan → front-loaded Arch/QA spec (composed ``) → **spec gate** → per-child **inline `TaskFlow`** lanes → combined-diff review → finish (squash PR). `epicSchemas(ns?)` / `epicBag(...)`. | | `workflows/rig-task.tsx` | Thin `` wrapper over `TaskFlow` (the standalone `/rig-task`). | | `workflows/rig-epic.tsx` | Thin `` wrapper over `EpicFlow` (the standalone `/rig-epic`). | -| `workflows/rig-crank.tsx` | **Autonomous build loop** (no skill counterpart): advisor-picks the next ready ticket from a scope, classifies epic-vs-task, composes `EpicFlow`/`TaskFlow` **inline**, verifies with an evidence-based **risk-probe gate**, lands, and loops (`continueAsNewEvery` for longevity) until the backlog is dry. | +| `workflows/rig-loop.tsx` | **Autonomous build loop** (no skill counterpart): advisor-picks the next ready ticket from a scope, classifies epic-vs-task, composes `EpicFlow`/`TaskFlow` **inline**, verifies with an evidence-based **risk-probe gate**, lands, and loops (`continueAsNewEvery` for longevity) until the backlog is dry. | | `workflows/rig-delegation-spike.tsx` | **Spike / evaluation** — points Smithers' off-the-shelf `DelegationChain` at one ask, to compare the delegation suite against the hand-built rig loop. Reference, not a canonical workflow. | | `ui/rig-epic.tsx`, `ui/rig-task.tsx` | The `` dashboards (`smithers ui `). | | `agents.example.ts` | **Reference only** — a machine-generated `agents.ts`. See "Consuming project" below. | @@ -28,7 +28,7 @@ Smithers run *executes*. The graph surface can **enforce** what prose can only `rig-task`/`rig-epic` used to be monolithic workflows that fanned children out via childRun ``. That boundary was opaque (the monitor couldn't see into children) and a paused child could fault the whole parent. The graph is now split -into **fragments** (`flows/*.tsx`) that a parent renders **inline**: `rig-crank` +into **fragments** (`flows/*.tsx`) that a parent renders **inline**: `rig-loop` composes `TaskFlow`/`EpicFlow`, and `EpicFlow` composes a `TaskFlow` per child — all in **one run**, with native cross-node deps and full time-travel, no childRun. The seams: a `tables` bag (so a fragment never assumes table *names* in the active diff --git a/smithers/workflows/flows/epic-flow.tsx b/smithers/workflows/flows/epic-flow.tsx index a2bc297..c96483f 100644 --- a/smithers/workflows/flows/epic-flow.tsx +++ b/smithers/workflows/flows/epic-flow.tsx @@ -12,10 +12,10 @@ import { TaskFlow } from "./task-flow"; * Same contract as TaskFlow: `tables` is a bag mapping each epic table name to the * composer's OutputTarget (build with `epicBag(outputs, ns?)`, register with * `epicSchemas(ns?)`); `childTables` is the task bag its inline child TaskFlows write to - * (build with `taskBag(outputs, tables.childRun, "child")`). Composed inline by rig-crank + * (build with `taskBag(outputs, tables.childRun, "child")`). Composed inline by rig-loop * or run standalone by the thin rig-epic wrapper — one run, native deps, no childRun. * - * EpicFlow keeps literal node ids (it is composed one epic at a time; a crank Loop + * EpicFlow keeps literal node ids (it is composed one epic at a time; a rig-loop Loop * iteration-scopes across epics), so it takes no idPrefix — but each child TaskFlow is * namespaced by `child--`. */ @@ -135,7 +135,7 @@ export const EPIC_TABLES = { const EPIC_KEYS = Object.keys(EPIC_TABLES) as (keyof typeof EPIC_TABLES)[]; const nsKey = (ns: string, k: string) => (ns ? `${ns}_${k}` : k); -/** Register EpicFlow's schemas, optionally namespaced (rig-crank namespaces to avoid its task-branch tables). */ +/** Register EpicFlow's schemas, optionally namespaced (rig-loop namespaces to avoid its task-branch tables). */ export const epicSchemas = (ns = ""): Record => Object.fromEntries(Object.entries(EPIC_TABLES).map(([k, v]) => [nsKey(ns, k), v])); diff --git a/smithers/workflows/flows/task-flow.tsx b/smithers/workflows/flows/task-flow.tsx index 107d53e..2253e21 100644 --- a/smithers/workflows/flows/task-flow.tsx +++ b/smithers/workflows/flows/task-flow.tsx @@ -9,7 +9,7 @@ import { providers } from "../../agents"; * TaskFlow — the /rig-task graph as a COMPOSABLE React fragment (no ). * * The same nodes as the standalone rig-task workflow, but authored as a function - * component so a parent (rig-crank, EpicFlow) can render it INLINE — one graph, one + * component so a parent (rig-loop, EpicFlow) can render it INLINE — one graph, one * run, native deps, full time-travel — instead of a childRun . * * Composition contract: @@ -99,7 +99,7 @@ const nsKey = (ns: string, k: string) => (ns ? `${ns}_${k}` : k); /** * Register TaskFlow's schemas in a createSmithers call, optionally namespaced. - * createSmithers({ ...taskSchemas() }) // canonical keys (rig-task, rig-crank) + * createSmithers({ ...taskSchemas() }) // canonical keys (rig-task, rig-loop) * createSmithers({ ...taskSchemas("child") }) // child_preflight, … (rig-epic — no collision) */ export const taskSchemas = (ns = ""): Record => @@ -369,7 +369,7 @@ Run the /rig-review skill for this unit: walk \`${d[nid("preflight")] ? ".claude then={ /* Dep on `setup` only (non-looped → stable id). review-find is a SIBLING LOOPED node; a hard dep on it deadlocks when TaskFlow is nested in an outer - Loop (the crank) — deps match physical ids, and the looped review-find gains + Loop (the repeat) — deps match physical ids, and the looped review-find gains a suffix the bare dep key can't match. The enclosing Branch already orders this after review-find; findings come from the suffix-lenient ctx.latest. */ diff --git a/smithers/workflows/rig-delegation-spike.tsx b/smithers/workflows/rig-delegation-spike.tsx index 26efa7c..350468c 100644 --- a/smithers/workflows/rig-delegation-spike.tsx +++ b/smithers/workflows/rig-delegation-spike.tsx @@ -1,7 +1,7 @@ // smithers-source: seeded // smithers-metadata-version: 1 // smithers-display-name: rig-delegation-spike — DelegationChain evaluation -// smithers-description: SPIKE. Points Smithers' off-the-shelf DelegationChain (recursive tiered delegation: refine → decompose → derisk → execute → score) at a single ask, to evaluate whether the suite could replace hand-built rig-crank/rig-epic orchestration. Not wired to the tracker or the integration-branch model — that's the open question. +// smithers-description: SPIKE. Points Smithers' off-the-shelf DelegationChain (recursive tiered delegation: refine → decompose → derisk → execute → score) at a single ask, to evaluate whether the suite could replace hand-built rig-loop/rig-epic orchestration. Not wired to the tracker or the integration-branch model — that's the open question. // smithers-tags: rig, delegation, spike, evaluation /** @jsxImportSource smithers-orchestrator */ import { createSmithers, DelegationChain, delegationSchemas } from "smithers-orchestrator"; @@ -17,7 +17,7 @@ import { providers } from "../agents"; * derisk probes, dependency-ordered leaf execution with gates + budgets, and scoring — * replanning affected subtrees as rows land, without restarting. * - * What we get for free (things we hand-build in rig-crank/rig-epic): + * What we get for free (things we hand-build in rig-loop/rig-epic): * - recursive decomposition (rig-epic's plan) + level-by-level fan-out * - per-node backpressure, budgets (maxUsd/maxMinutes → Aspects), scoring * - tiered model routing (strongest-first with fallback) — our ROLE map, generalized diff --git a/smithers/workflows/rig-epic.tsx b/smithers/workflows/rig-epic.tsx index 44f5fde..350d9ec 100644 --- a/smithers/workflows/rig-epic.tsx +++ b/smithers/workflows/rig-epic.tsx @@ -10,7 +10,7 @@ import { taskSchemas, taskBag } from "./flows/task-flow"; /** * Standalone rig-epic = the EpicFlow fragment under its own . The graph - * lives in flows/epic-flow.tsx so rig-crank can compose it INLINE (one run, native + * lives in flows/epic-flow.tsx so rig-loop can compose it INLINE (one run, native * deps, no childRun). Register EpicFlow's own tables + the child TaskFlow tables * (namespaced "child_" — no collision with epic's own reviewFix/etc.). */ diff --git a/smithers/workflows/rig-crank.tsx b/smithers/workflows/rig-loop.tsx similarity index 92% rename from smithers/workflows/rig-crank.tsx rename to smithers/workflows/rig-loop.tsx index 50e178a..aa0d35f 100644 --- a/smithers/workflows/rig-crank.tsx +++ b/smithers/workflows/rig-loop.tsx @@ -1,6 +1,6 @@ // smithers-source: seeded // smithers-metadata-version: 1 -// smithers-display-name: rig-crank — autonomous build loop (SKETCH) +// smithers-display-name: rig-loop — autonomous build loop (SKETCH) // smithers-description: Drains a ticket backlog one unit at a time — advisor-picks the next ready ticket, builds it via rig-task, verifies with evidence-based backpressure, lands it, and carries distilled state across generations until the backlog is dry or the budget is spent. First-pass sketch. // smithers-tags: rig, autonomous, loop, sketch /** @jsxImportSource smithers-orchestrator */ @@ -11,7 +11,7 @@ import { TaskFlow, taskSchemas, taskBag, taskResultSchema } from "./flows/task-f import { EpicFlow, epicSchemas, epicBag } from "./flows/epic-flow"; /** - * rig-crank — an autonomous build loop grounded in Smithers context-engineering: + * rig-loop — an autonomous build loop grounded in Smithers context-engineering: * pick → build → verify → land → repeat, until the backlog is dry or the budget's spent. * * Theses it embodies (from smithers.sh/guides/context-engineering): @@ -25,7 +25,7 @@ import { EpicFlow, epicSchemas, epicBag } from "./flows/epic-flow"; * - Longevity: the Loop's own `continueAsNewEvery` /clears and carries state across generations. * * Hardening path (native components that replace the hand-rolled bits, once the core proves out): - * - — serialize the irreversible LAND across concurrent cranks (one merge at a time). + * - — serialize the irreversible LAND across concurrent loop iterations (one merge at a time). * - / — compensating rollback if a land half-completes. * - — advisor decides autonomously, but escalates to a human on genuine uncertainty * (resolves "must I always drive this myself?" — hands off only the hard calls). @@ -78,13 +78,13 @@ export default smithers((ctx) => { const childTablesForEpic = taskBag(outputs, epicTables.childRun, "child"); return ( - + {/* Keep the ORCHESTRATOR lean so it can run all day: a hard token ceiling on the whole run; children summarize into it rather than dumping diffs/logs. */} - {/* The crank. Stop when the backlog is dry (until), or the safety ceiling (maxIterations); + {/* The loop. Stop when the backlog is dry (until), or the safety ceiling (maxIterations); /clear and carry loop state every 3 units so context never bloats across a long drain. */} - + {/* 1 · PICK — advisor names the next READY, TOP-LEVEL work item and classifies its SHAPE. Top-level only: children of an epic are drained INSIDE rig-epic, never picked here, or @@ -105,7 +105,7 @@ export default smithers((ctx) => { /* EPIC — compose EpicFlow INLINE (one run, native deps; no childRun). It front-loads the spec (advisor-gated), stacks each child as an inline TaskFlow, reviews the combined diff, and squashes to the trunk. EpicFlow OWNS its own gate + landing, - so the crank doesn't re-verify/land — self-contained through trunk (merge:true). */ + so the loop doesn't re-verify/land — self-contained through trunk (merge:true). */ { /> } else={ - /* TASK — the crank owns the gate: build → e2e verify → land. */ + /* TASK — the loop owns the gate: build → e2e verify → land. */ {/* 2 · BUILD — TaskFlow composed INLINE (one run, native deps, full time-travel; no childRun Subflow). idPrefix "task-" namespaces its nodes; its terminal summary lands in outputs.taskUnit. phase "both" runs the FULL rig-task incl. the - review-bot (Bugbot) loop, so the unit is bot-clean BEFORE the crank verifies+lands. + review-bot (Bugbot) loop, so the unit is bot-clean BEFORE the loop verifies+lands. spec pre-cleared so it never pauses. */} - {() => `The crank has stopped for scope "${ctx.input.scope || "the backlog"}". Report {done, built, note}: done=true if the backlog is dry (no ready tickets remain), false if it stopped on the ceiling/budget. \`built\` = the tickets this run moved to Done (check the tracker + merged PRs). Keep \`note\` to one line.`} + {() => `The loop has stopped for scope "${ctx.input.scope || "the backlog"}". Report {done, built, note}: done=true if the backlog is dry (no ready tickets remain), false if it stopped on the ceiling/budget. \`built\` = the tickets this run moved to Done (check the tracker + merged PRs). Keep \`note\` to one line.`} ); diff --git a/smithers/workflows/rig-task.tsx b/smithers/workflows/rig-task.tsx index 17d3211..b5e82c8 100644 --- a/smithers/workflows/rig-task.tsx +++ b/smithers/workflows/rig-task.tsx @@ -9,7 +9,7 @@ import { TaskFlow, taskSchemas, taskBag, taskInputSchema, taskResultSchema } fro /** * Standalone rig-task = the TaskFlow fragment under its own . The graph - * lives in flows/task-flow.tsx so rig-crank / EpicFlow can render it INLINE (one run, + * lives in flows/task-flow.tsx so rig-loop / EpicFlow can render it INLINE (one run, * native deps, full time-travel) instead of a childRun . Registering * TASK_TABLES here is what makes `outputs` carry the fragment's tables. */ From b356f5864c0bc2bd4ce564d01eb8d03febbbf3e8 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:00:46 +0000 Subject: [PATCH 07/18] =?UTF-8?q?draft(rig-sync):=20spec=20=E2=87=84=20cod?= =?UTF-8?q?e=20reconciler=20(terraform=20loop=20for=20code)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design-draft skill: treat the spec as desired state and code as actual state, compute bidirectional drift (plan, read-only), then reconcile via a pluggable sink — an ephemeral Smithers workflow (default), a tracked milestone of tickets, or report-only. Never edits product code directly; work lands through gated rig-task/rig-plan or a Smithers workflow lane. Modeled on rig-plan; extractor is a project-supplied adapter (rig-tracker pattern). Not wired into DEFAULT_SKILLS — proposal for discussion. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- skills/rig-sync/SKILL.md | 190 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 skills/rig-sync/SKILL.md diff --git a/skills/rig-sync/SKILL.md b/skills/rig-sync/SKILL.md new file mode 100644 index 0000000..ed4f4d3 --- /dev/null +++ b/skills/rig-sync/SKILL.md @@ -0,0 +1,190 @@ +--- +name: rig-sync +description: "Keep a repo in sync with its spec — the terraform loop for code. Treats the spec as desired state and the code as actual state, computes the drift between them (both directions), and reconciles it. `plan` (default): read-only drift report — what the spec demands that the code lacks, and what the code has that the spec doesn't. `apply`: reconcile the actionable drift through a pluggable sink — an ephemeral Smithers workflow (default), a tracked milestone of tickets, or report-only — and it never edits product code directly. Triggers on: 'rig-sync', 'sync the repo to the spec', 'spec sync', 'spec drift', 'drift between spec and code', 'reconcile spec and code', 'is the code still in sync with the spec', 'what changed vs the spec', 'terraform for code', 'plan the spec drift'." +argument-hint: "[plan | apply] [spec-glob] [--section ] [--truth spec|code] [--sink workflow|backlog|report] [--yes] — default 'plan' (read-only drift report)" +--- + +# Spec ⇄ code reconciler + +Treat the **spec as desired state** and the **code as actual state**, then run the +terraform loop over them: + +- **`plan`** (default) — compute the **drift** between spec and code, in both + directions, and write a report. **Read-only** — it changes neither the spec nor + the code (the same posture as `/rig-review find`). +- **`apply`** — reconcile the actionable drift. rig-sync's `apply` is **not + "write code"** — code generated from a spec is non-deterministic and must be + reviewed. It routes the drift to a **pluggable sink** (below), each of which + keeps the review/gate; the only things `apply` writes itself are spec-side + artifacts (the projection, doc drift), which are safe. + +Unlike `/rig-plan` — greenfield, spec → *initial* backlog with nothing to diff +against — rig-sync is **continuous**: it diffs the spec against the code you +already have and proposes only what reconciles the difference. + +## Reconciliation sinks + +Where `apply` sends the drift is a choice about **lifespan × audience**, not a +fixed pipeline. A ticket does two jobs — *dispatch* work to a worker, and +*govern/record* it for humans. For AI work the dispatch half is overhead (the +worker is autonomous), so tickets are not the default. + +- **`workflow`** (default) — generate a **Smithers workflow** from the drift and + run it ephemerally: it does the reconciliation now, with its own approval gate, + durability, retries, and a live run record — governance without a permanent + ticket. Best for the continuous case, where board churn would be noise. +- **`backlog`** — create **one milestone** ("reconcile `` → drift vN") and + hand the drift to `/rig-plan`, so units land as tickets grouped under that + milestone (not loose issues). Best when the drift needs human prioritization, + scheduling, or cross-team visibility over time. +- **`report`** — write the drift + proposed units to files only; a human decides. + +The **drift report is written regardless of sink** — it is the durable record of +intent that lets ephemeral execution be resumed or explained later. + +## Configuration + +Reads `.rig/config.json` (defaults in parentheses): + +- `sync.specGlob` — the desired-state source: one file or a glob of spec/catalog + docs (default: first of `SPEC.md`, `specs/prd.md`, `docs/prd.md`). +- `sync.projection` — optional path to a **generated, machine-readable + projection** of the spec (e.g. `.rig/spec.lock.json` or `spec/`). The diffable + middle layer — the analogue of a terraform state file. When set, rig-sync + regenerates it from the spec and diffs *it* against code; unset, the agent + reasons prose-spec vs code directly (coarser). +- `sync.extractor` — a **project-supplied adapter** that enumerates the actual + surface of the code as structured JSON (see *The extractor adapter*). + Resolver: `.rig/rig-sync-extractor` if executable, else the value of this key, + else none. With no extractor, actual-state discovery is best-effort agent + reasoning over `sourceScope` — **say so** in the report. +- `sync.preserve` — globs of hand-maintained files/regions inside the projection + that are **never regenerated** — preserved verbatim; rig-sync only cross-checks + them and flags contradictions. +- `sync.truth` — default direction of truth when spec and code disagree: + `spec` (spec wins → code drift becomes work) · `code` (code wins → spec drift + becomes a doc update) · `ask` (default — report both, human decides). +- `sync.apply.sink` — default reconciliation sink: `workflow` (default) · + `backlog` · `report`. `--sink` overrides per run. +- `sync.driftReport` — where the report is written (default `.rig/DRIFT.md`). +- Reused: `sourceScope` (areas the extractor/agent scans), `agents.architect` + (extraction + drift reasoning), `vcs.baseRef` (projection baseline for + re-runs), and — for the `backlog` sink only — `tracker.*` + `tracker.board`. + +`apply` delegates: the `backlog` sink to `/rig-plan` (which fans out to +`/rig-epic` / `/rig-sprint` / `/rig-issue`); the `workflow` sink to Smithers. It +never invokes `/rig-task` to write code itself. + +## The extractor adapter + +Drift only generalizes if rig-sync doesn't hardcode what "a surface" is. The +project owns that, exactly like the `rig-tracker` adapter owns "a board." The +extractor is any executable that prints JSON to stdout: + +```json +{ + "surface": [ + { "kind": "topic", "id": "orders.filled", "role": "producer", + "owner": "services/matching", "ref": "services/matching/publish.ts:42" } + ], + "invariants": [ + { "assert": "every topic has exactly one producer" } + ] +} +``` + +Each element is keyed by `(kind, id)`. rig-sync diffs the code's `surface` +against the spec's expected surface (from the projection) on that key, staying +domain-agnostic: `kind` can be `topic`, `rpc`, `endpoint`, `table`, `flag`, +`cli-command` — whatever the extractor emits. `invariants` are project-declared +assertions rig-sync checks against the merged set. + +## Arguments + +`$ARGUMENTS` begins with an optional verb, then args: + +- **`plan [spec-glob]`** (default) — drift report only. +- **`apply [spec-glob]`** — reconcile after approval. +- `[spec-glob]` — override `sync.specGlob`. +- `--section ` — restrict to one spec section/milestone (match a heading). +- `--truth spec|code` — override `sync.truth`. +- `--sink workflow|backlog|report` — override `sync.apply.sink`. +- `--yes` — skip the `apply` approval gate. Default is to STOP for review. + +## Procedure + +1. **Resolve** config, the spec source(s), and the extractor. If no spec is + found, ask. Read the whole spec (or just the `--section`). + +2. **Build the desired-state projection — fresh context, `agents.architect`.** + When `sync.projection` is set, extract the spec's expected surface into the + projection format. **Do not invent**: record ambiguity as anomalies and + spec-internal contradictions in the report, never as invented surface. + **Preserve `sync.preserve` regions verbatim.** No projection configured → skip + and carry the spec forward as prose. + +3. **Extract the actual state.** Run the extractor over `sourceScope`; capture + its `surface` + `invariants`. No extractor → `agents.architect` enumerates the + surface heuristically from `sourceScope`, marked **best-effort** in the report. + +4. **Diff desired vs actual — both directions.** Classify every element: + - **missing** — in the spec, absent from the code → reconcilable *work*. + - **undocumented** — in the code, absent from the spec → a *spec* update, or + out-of-scope code to flag. + - **diverged** — present on both sides but attributes disagree → a decision, + resolved by `truth`. + Then check the extractor's `invariants` against the merged set; a violation is + a finding in its own right. + +5. **Report — then STOP** (where `plan` ends). Write `sync.driftReport` and print + a summary: counts per class, invariant violations, and which side `truth` + favors for each diverged item. This is the terraform *plan*. + +6. **Apply — on approval only, and never by editing product code.** Split the + drift: **missing** + spec-winning **diverged** are *work*; **undocumented** + + code-winning **diverged** are *spec/doc* fixes. Then, by sink: + - **`workflow`** → synthesize a scoped drift-spec and generate a **Smithers + workflow** that reconciles it — one lane per unit, each built through + `/rig-task` *inside the workflow* so the RED→GREEN→review gates still hold, + with a workflow-level approval before anything merges. Ephemeral: no + tickets. (Smithers unavailable → fall back to `backlog`, and say so.) + - **`backlog`** → create one milestone `reconcile → drift vN` and hand + the drift-spec to `/rig-plan`; the units land as tickets under that + milestone on the board. + - **`report`** → write the drift-spec + proposed units to `.rig/plan.md`. + For the *spec/doc* side (any sink): write the projection + a **proposed** + catalog/doc change (docs are safe) and flag it for human confirmation — never + silently rewrite the human spec. + **Refresh + gate.** Regenerate the projection (preserving `sync.preserve`). + For every unit whose contract drifted, reset its board/run gate no higher than + a *contract-re-verify* state so a stale acknowledgment can't ride along. + +7. **Report + hand off.** Print what was produced — the workflow run (or the + milestone + ticket IDs + board link, or the plan file), plus which spec-side + files changed — and the next step. rig-sync's job ends at **reconciliation in + motion + an updated projection**, not at modified product code. + +## Notes + +- **Plan/apply, not auto-code.** `plan` is a read-only drift report; `apply` + routes drift to a sink that keeps a gate — never keystrokes into your source. + That boundary is what makes the gate meaningful, same as `/rig-plan` never + starting work and `/rig-review find` never editing. +- **Tickets aren't the default.** For AI work the dispatch half of a ticket is + overhead. The ephemeral `workflow` sink gives governance (approval + live run + + history) without permanent board churn; reach for `backlog` only when the drift + needs human scheduling or lasting cross-team visibility. +- **The record survives the run.** The drift report is written for every sink, so + ephemeral execution is still resumable and explainable after the fact. +- **Direction of truth is a human call.** rig-sync reports both directions and + defaults to `ask`; it auto-picks only under `sync.truth` / `--truth`. +- **The adapter is the seam.** Without `sync.extractor` this degrades to + best-effort agent reasoning — fine for a read, not authoritative. A crisp + extractor (a pub/sub registry, an OpenAPI surface, a schema catalog) makes + drift precise and portable. +- **Re-runnable / idempotent.** Match drift to existing work by surface + `(kind, id)` so a re-run proposes only *new* drift and won't duplicate a + workflow lane or a ticket already in flight. +- **Degrades.** No projection → prose vs code. No extractor → heuristic surface. + No Smithers → `workflow` falls back to `backlog`. `tracker: none` → `backlog` + falls back to `report`. Useful at every rung. From 976bcfaa75ed4a27b2744fd242c245b42b6c16d2 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:41 +0000 Subject: [PATCH 08/18] =?UTF-8?q?demo(rig-sync):=20notes-api=20sandbox=20?= =?UTF-8?q?=E2=80=94=20spec=20=E2=87=84=20code=20drift=20on=20a=20tiny=20H?= =?UTF-8?q?TTP=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demos/ is a home for try-it sandboxes. notes-api ships a spec, Express-style code with three planted drifts (missing DELETE, PUT<->PATCH divergence, undocumented GET /health), a real grep-based extractor adapter, and the rig-sync skill + rig-architect agent copied in so /rig-sync plan runs in place. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- demos/README.md | 12 ++ .../notes-api/.claude/agents/rig-architect.md | 60 ++++++ .../.claude/skills/rig-sync/SKILL.md | 190 ++++++++++++++++++ demos/notes-api/.gitignore | 5 + demos/notes-api/.rig/config.json | 19 ++ demos/notes-api/.rig/rig-sync-extractor | 72 +++++++ demos/notes-api/README.md | 64 ++++++ demos/notes-api/SPEC.md | 29 +++ demos/notes-api/src/app.js | 51 +++++ 9 files changed, 502 insertions(+) create mode 100644 demos/README.md create mode 100644 demos/notes-api/.claude/agents/rig-architect.md create mode 100644 demos/notes-api/.claude/skills/rig-sync/SKILL.md create mode 100644 demos/notes-api/.gitignore create mode 100644 demos/notes-api/.rig/config.json create mode 100755 demos/notes-api/.rig/rig-sync-extractor create mode 100644 demos/notes-api/README.md create mode 100644 demos/notes-api/SPEC.md create mode 100644 demos/notes-api/src/app.js diff --git a/demos/README.md b/demos/README.md new file mode 100644 index 0000000..6c9ff24 --- /dev/null +++ b/demos/README.md @@ -0,0 +1,12 @@ +# rig demos + +Small, self-contained sandboxes for trying a rig skill without wiring it into a +real project. Each demo ships its own `.rig/config.json` and copies in just the +skill(s) + agent(s) it needs under `.claude/`, so you can `cd` in and run. + +| Demo | Skill | What it shows | +|------|-------|---------------| +| [`notes-api`](./notes-api) | `rig-sync` | Spec ⇄ code drift on a tiny HTTP API — `plan` finds it, `apply` (report sink) proposes the fix. | + +These are illustrative, not production scaffolding. In a real repo you'd install +rig with `rig-onboard` instead of copying skills in by hand. diff --git a/demos/notes-api/.claude/agents/rig-architect.md b/demos/notes-api/.claude/agents/rig-architect.md new file mode 100644 index 0000000..41f3a01 --- /dev/null +++ b/demos/notes-api/.claude/agents/rig-architect.md @@ -0,0 +1,60 @@ +--- +name: rig-architect +description: Tech lead agent for planning, architecture decisions, and ticket creation. Use when breaking down a feature, designing a solution, evaluating tradeoffs, or creating implementation tickets. Invoke before coding begins on any non-trivial change. +model: opus +tools: Read, Bash, Grep, Glob, WebFetch, WebSearch, TodoWrite, LSP +--- + +You are the tech lead. Your job is to think before code is written. + +## Your responsibilities + +- Read requirements (specs, product docs, user intent) and translate them into a concrete implementation plan. +- Identify what already exists vs. what needs to be built. +- Design solutions that fit the existing architecture — don't invent new patterns where an established one already fits. +- Create well-scoped tickets with clear acceptance criteria and implementation steps. +- Flag risks, dependencies, and open questions before work starts. +- Decide which tickets can be parallelized and which must be sequential. + +## Non-negotiables you enforce + +- **Respect the project's established runtime and toolchain.** Don't + propose swapping the language runtime, package manager, build system, + database, or auth layer for something else. Design within them. +- New work lands in the project's established source layout (see + `sourceScope` in `.rig/config.json`) — don't scatter a parallel tree. +- **Extraction over duplication.** Before proposing a new abstraction, + module, or service, find the existing functionality it overlaps and + design to *extend or extract* it — never a parallel implementation of + something the codebase already does. Naming a competing abstraction is + a design smell. +- **Surface the decision.** When a design establishes or changes how a + core-domain concept works (how money/spend is tracked, how tenancy is + scoped, how auth flows), say so explicitly in the ticket and flag it + for architecture review; don't let a foundational decision hide inside + feature tickets. + +## How you work + +1. Read the relevant files before forming opinions. Use the project's + own docs (agent/README/spec files) for context. +2. Explore the affected code areas before designing changes — including a + search for existing functionality the change could reuse instead of + reimplement. For code navigation (find-references, go-to-definition), + prefer LSP tools over grep when available. +3. Create tickets through the project's configured tracker + (`tracker.provider` in `.rig/config.json` — Linear, GitHub + Issues, etc.). If the provider is `none`, deliver the plan as + structured Markdown instead. +4. Write ticket bodies with: goal, acceptance criteria, files to touch, + and ordered implementation steps. +5. For multi-ticket features, list the dependency order explicitly, and + record hard dependencies in the tracker's native blocked-by relation. + +## Output style + +- Lead with the decision or plan, not the reasoning. +- Use tables for tradeoff comparisons. +- Use numbered lists for ordered steps. +- Flag open questions with **Decision needed:**. +- Be direct about what you'd cut from scope. diff --git a/demos/notes-api/.claude/skills/rig-sync/SKILL.md b/demos/notes-api/.claude/skills/rig-sync/SKILL.md new file mode 100644 index 0000000..ed4f4d3 --- /dev/null +++ b/demos/notes-api/.claude/skills/rig-sync/SKILL.md @@ -0,0 +1,190 @@ +--- +name: rig-sync +description: "Keep a repo in sync with its spec — the terraform loop for code. Treats the spec as desired state and the code as actual state, computes the drift between them (both directions), and reconciles it. `plan` (default): read-only drift report — what the spec demands that the code lacks, and what the code has that the spec doesn't. `apply`: reconcile the actionable drift through a pluggable sink — an ephemeral Smithers workflow (default), a tracked milestone of tickets, or report-only — and it never edits product code directly. Triggers on: 'rig-sync', 'sync the repo to the spec', 'spec sync', 'spec drift', 'drift between spec and code', 'reconcile spec and code', 'is the code still in sync with the spec', 'what changed vs the spec', 'terraform for code', 'plan the spec drift'." +argument-hint: "[plan | apply] [spec-glob] [--section ] [--truth spec|code] [--sink workflow|backlog|report] [--yes] — default 'plan' (read-only drift report)" +--- + +# Spec ⇄ code reconciler + +Treat the **spec as desired state** and the **code as actual state**, then run the +terraform loop over them: + +- **`plan`** (default) — compute the **drift** between spec and code, in both + directions, and write a report. **Read-only** — it changes neither the spec nor + the code (the same posture as `/rig-review find`). +- **`apply`** — reconcile the actionable drift. rig-sync's `apply` is **not + "write code"** — code generated from a spec is non-deterministic and must be + reviewed. It routes the drift to a **pluggable sink** (below), each of which + keeps the review/gate; the only things `apply` writes itself are spec-side + artifacts (the projection, doc drift), which are safe. + +Unlike `/rig-plan` — greenfield, spec → *initial* backlog with nothing to diff +against — rig-sync is **continuous**: it diffs the spec against the code you +already have and proposes only what reconciles the difference. + +## Reconciliation sinks + +Where `apply` sends the drift is a choice about **lifespan × audience**, not a +fixed pipeline. A ticket does two jobs — *dispatch* work to a worker, and +*govern/record* it for humans. For AI work the dispatch half is overhead (the +worker is autonomous), so tickets are not the default. + +- **`workflow`** (default) — generate a **Smithers workflow** from the drift and + run it ephemerally: it does the reconciliation now, with its own approval gate, + durability, retries, and a live run record — governance without a permanent + ticket. Best for the continuous case, where board churn would be noise. +- **`backlog`** — create **one milestone** ("reconcile `` → drift vN") and + hand the drift to `/rig-plan`, so units land as tickets grouped under that + milestone (not loose issues). Best when the drift needs human prioritization, + scheduling, or cross-team visibility over time. +- **`report`** — write the drift + proposed units to files only; a human decides. + +The **drift report is written regardless of sink** — it is the durable record of +intent that lets ephemeral execution be resumed or explained later. + +## Configuration + +Reads `.rig/config.json` (defaults in parentheses): + +- `sync.specGlob` — the desired-state source: one file or a glob of spec/catalog + docs (default: first of `SPEC.md`, `specs/prd.md`, `docs/prd.md`). +- `sync.projection` — optional path to a **generated, machine-readable + projection** of the spec (e.g. `.rig/spec.lock.json` or `spec/`). The diffable + middle layer — the analogue of a terraform state file. When set, rig-sync + regenerates it from the spec and diffs *it* against code; unset, the agent + reasons prose-spec vs code directly (coarser). +- `sync.extractor` — a **project-supplied adapter** that enumerates the actual + surface of the code as structured JSON (see *The extractor adapter*). + Resolver: `.rig/rig-sync-extractor` if executable, else the value of this key, + else none. With no extractor, actual-state discovery is best-effort agent + reasoning over `sourceScope` — **say so** in the report. +- `sync.preserve` — globs of hand-maintained files/regions inside the projection + that are **never regenerated** — preserved verbatim; rig-sync only cross-checks + them and flags contradictions. +- `sync.truth` — default direction of truth when spec and code disagree: + `spec` (spec wins → code drift becomes work) · `code` (code wins → spec drift + becomes a doc update) · `ask` (default — report both, human decides). +- `sync.apply.sink` — default reconciliation sink: `workflow` (default) · + `backlog` · `report`. `--sink` overrides per run. +- `sync.driftReport` — where the report is written (default `.rig/DRIFT.md`). +- Reused: `sourceScope` (areas the extractor/agent scans), `agents.architect` + (extraction + drift reasoning), `vcs.baseRef` (projection baseline for + re-runs), and — for the `backlog` sink only — `tracker.*` + `tracker.board`. + +`apply` delegates: the `backlog` sink to `/rig-plan` (which fans out to +`/rig-epic` / `/rig-sprint` / `/rig-issue`); the `workflow` sink to Smithers. It +never invokes `/rig-task` to write code itself. + +## The extractor adapter + +Drift only generalizes if rig-sync doesn't hardcode what "a surface" is. The +project owns that, exactly like the `rig-tracker` adapter owns "a board." The +extractor is any executable that prints JSON to stdout: + +```json +{ + "surface": [ + { "kind": "topic", "id": "orders.filled", "role": "producer", + "owner": "services/matching", "ref": "services/matching/publish.ts:42" } + ], + "invariants": [ + { "assert": "every topic has exactly one producer" } + ] +} +``` + +Each element is keyed by `(kind, id)`. rig-sync diffs the code's `surface` +against the spec's expected surface (from the projection) on that key, staying +domain-agnostic: `kind` can be `topic`, `rpc`, `endpoint`, `table`, `flag`, +`cli-command` — whatever the extractor emits. `invariants` are project-declared +assertions rig-sync checks against the merged set. + +## Arguments + +`$ARGUMENTS` begins with an optional verb, then args: + +- **`plan [spec-glob]`** (default) — drift report only. +- **`apply [spec-glob]`** — reconcile after approval. +- `[spec-glob]` — override `sync.specGlob`. +- `--section ` — restrict to one spec section/milestone (match a heading). +- `--truth spec|code` — override `sync.truth`. +- `--sink workflow|backlog|report` — override `sync.apply.sink`. +- `--yes` — skip the `apply` approval gate. Default is to STOP for review. + +## Procedure + +1. **Resolve** config, the spec source(s), and the extractor. If no spec is + found, ask. Read the whole spec (or just the `--section`). + +2. **Build the desired-state projection — fresh context, `agents.architect`.** + When `sync.projection` is set, extract the spec's expected surface into the + projection format. **Do not invent**: record ambiguity as anomalies and + spec-internal contradictions in the report, never as invented surface. + **Preserve `sync.preserve` regions verbatim.** No projection configured → skip + and carry the spec forward as prose. + +3. **Extract the actual state.** Run the extractor over `sourceScope`; capture + its `surface` + `invariants`. No extractor → `agents.architect` enumerates the + surface heuristically from `sourceScope`, marked **best-effort** in the report. + +4. **Diff desired vs actual — both directions.** Classify every element: + - **missing** — in the spec, absent from the code → reconcilable *work*. + - **undocumented** — in the code, absent from the spec → a *spec* update, or + out-of-scope code to flag. + - **diverged** — present on both sides but attributes disagree → a decision, + resolved by `truth`. + Then check the extractor's `invariants` against the merged set; a violation is + a finding in its own right. + +5. **Report — then STOP** (where `plan` ends). Write `sync.driftReport` and print + a summary: counts per class, invariant violations, and which side `truth` + favors for each diverged item. This is the terraform *plan*. + +6. **Apply — on approval only, and never by editing product code.** Split the + drift: **missing** + spec-winning **diverged** are *work*; **undocumented** + + code-winning **diverged** are *spec/doc* fixes. Then, by sink: + - **`workflow`** → synthesize a scoped drift-spec and generate a **Smithers + workflow** that reconciles it — one lane per unit, each built through + `/rig-task` *inside the workflow* so the RED→GREEN→review gates still hold, + with a workflow-level approval before anything merges. Ephemeral: no + tickets. (Smithers unavailable → fall back to `backlog`, and say so.) + - **`backlog`** → create one milestone `reconcile → drift vN` and hand + the drift-spec to `/rig-plan`; the units land as tickets under that + milestone on the board. + - **`report`** → write the drift-spec + proposed units to `.rig/plan.md`. + For the *spec/doc* side (any sink): write the projection + a **proposed** + catalog/doc change (docs are safe) and flag it for human confirmation — never + silently rewrite the human spec. + **Refresh + gate.** Regenerate the projection (preserving `sync.preserve`). + For every unit whose contract drifted, reset its board/run gate no higher than + a *contract-re-verify* state so a stale acknowledgment can't ride along. + +7. **Report + hand off.** Print what was produced — the workflow run (or the + milestone + ticket IDs + board link, or the plan file), plus which spec-side + files changed — and the next step. rig-sync's job ends at **reconciliation in + motion + an updated projection**, not at modified product code. + +## Notes + +- **Plan/apply, not auto-code.** `plan` is a read-only drift report; `apply` + routes drift to a sink that keeps a gate — never keystrokes into your source. + That boundary is what makes the gate meaningful, same as `/rig-plan` never + starting work and `/rig-review find` never editing. +- **Tickets aren't the default.** For AI work the dispatch half of a ticket is + overhead. The ephemeral `workflow` sink gives governance (approval + live run + + history) without permanent board churn; reach for `backlog` only when the drift + needs human scheduling or lasting cross-team visibility. +- **The record survives the run.** The drift report is written for every sink, so + ephemeral execution is still resumable and explainable after the fact. +- **Direction of truth is a human call.** rig-sync reports both directions and + defaults to `ask`; it auto-picks only under `sync.truth` / `--truth`. +- **The adapter is the seam.** Without `sync.extractor` this degrades to + best-effort agent reasoning — fine for a read, not authoritative. A crisp + extractor (a pub/sub registry, an OpenAPI surface, a schema catalog) makes + drift precise and portable. +- **Re-runnable / idempotent.** Match drift to existing work by surface + `(kind, id)` so a re-run proposes only *new* drift and won't duplicate a + workflow lane or a ticket already in flight. +- **Degrades.** No projection → prose vs code. No extractor → heuristic surface. + No Smithers → `workflow` falls back to `backlog`. `tracker: none` → `backlog` + falls back to `report`. Useful at every rung. diff --git a/demos/notes-api/.gitignore b/demos/notes-api/.gitignore new file mode 100644 index 0000000..0873661 --- /dev/null +++ b/demos/notes-api/.gitignore @@ -0,0 +1,5 @@ +# generated by rig-sync runs +.rig/DRIFT.md +.rig/plan.md +.rig/spec.lock.json +node_modules/ diff --git a/demos/notes-api/.rig/config.json b/demos/notes-api/.rig/config.json new file mode 100644 index 0000000..4b7fcc7 --- /dev/null +++ b/demos/notes-api/.rig/config.json @@ -0,0 +1,19 @@ +{ + "project": { + "name": "notes-api" + }, + "sourceScope": ["src"], + "agents": { + "architect": "rig-architect" + }, + "tracker": { + "provider": "none" + }, + "sync": { + "specGlob": "SPEC.md", + "extractor": ".rig/rig-sync-extractor", + "truth": "ask", + "apply": { "sink": "report" }, + "driftReport": ".rig/DRIFT.md" + } +} diff --git a/demos/notes-api/.rig/rig-sync-extractor b/demos/notes-api/.rig/rig-sync-extractor new file mode 100755 index 0000000..71f3b60 --- /dev/null +++ b/demos/notes-api/.rig/rig-sync-extractor @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""rig-sync extractor adapter for the notes-api demo. + +Enumerates the code's ACTUAL surface — the HTTP routes declared in src/ — and +prints it as the JSON contract rig-sync expects: + + { "surface": [ { kind, id, role, owner, ref }, ... ], + "invariants": [ { assert }, ... ] } + +rig-sync diffs this `surface` against the spec's expected surface (keyed by +`(kind, id)`) and checks the `invariants`. This is the ONLY demo-specific piece: +the project owns "what a surface is," rig-sync owns the diff/report/reconcile +loop — the same split as the rig-tracker adapter. + +Usage: rig-sync-extractor [--files ...] (default: scan src/) +Informational only; never exits non-zero. +""" +import json +import re +import sys +from pathlib import Path + +# app.get("/x", ...) / router.post('/x', ...) — method + quoted path +ROUTE = re.compile(r"""\b(?:app|router)\.(get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]""") + + +def scan(files): + surface, seen = [], set() + for f in files: + try: + lines = Path(f).read_text().splitlines() + except OSError: + continue + for n, line in enumerate(lines, 1): + m = ROUTE.search(line) + if not m: + continue + method = m.group(1).upper() + # normalise Express :id path params to the spec's {id} form + path = re.sub(r":([A-Za-z_][A-Za-z0-9_]*)", r"{\1}", m.group(2)) + sid = f"{method} {path}" + if sid in seen: + continue + seen.add(sid) + surface.append({ + "kind": "endpoint", + "id": sid, + "role": "route", + "owner": str(Path(f).parent), + "ref": f"{f}:{n}", + }) + return surface + + +def main(): + args = sys.argv[1:] + if args and args[0] == "--files": + files = args[1:] + else: + files = [str(p) for p in Path("src").rglob("*.js")] + out = { + "surface": scan(files), + "invariants": [ + {"assert": "every endpoint in SPEC.md has a handler in src/"}, + {"assert": "no undocumented endpoints — every code endpoint appears in SPEC.md"}, + ], + } + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/demos/notes-api/README.md b/demos/notes-api/README.md new file mode 100644 index 0000000..22c6e50 --- /dev/null +++ b/demos/notes-api/README.md @@ -0,0 +1,64 @@ +# notes-api — a rig-sync sandbox + +A tiny Notes HTTP API whose code has **drifted from its spec on purpose**. Use it +to try [`rig-sync`](../../skills/rig-sync/SKILL.md): treat `SPEC.md` as desired +state, `src/` as actual state, and reconcile. + +``` +notes-api/ +├── SPEC.md desired state — the API surface + invariants +├── src/app.js actual state — Express routes (with planted drift) +├── .rig/ +│ ├── config.json sync config (tracker: none, apply sink: report) +│ └── rig-sync-extractor the extractor adapter (enumerates code routes → JSON) +└── .claude/ rig-sync skill + rig-architect agent, copied in so it runs here +``` + +## Try it + +From this directory, in Claude Code: + +``` +/rig-sync plan +``` + +**Read-only.** It builds the desired surface from `SPEC.md`, runs the extractor +to get the actual surface from `src/`, diffs them, and writes `.rig/DRIFT.md`. + +### What `plan` should find + +Three drifts (plus both invariants violated): + +| Class | Element | Why | +|-------|---------|-----| +| **missing** | `DELETE /notes/{id}` | in the spec, no handler in `src/` → work to do | +| **diverged** | `PUT /notes/{id}` ⇄ `PATCH /notes/{id}` | spec wants a full replace (`PUT`); code implements `PATCH` on the same resource | +| **undocumented** | `GET /health` | code serves it, spec never mentions it → spec update or out-of-scope | + +Because `sync.truth` is `ask`, `plan` reports both directions and doesn't pick a +winner for the divergence — that's your call. + +### Then reconcile + +``` +/rig-sync apply --sink report +``` + +With `tracker: none` and the `report` sink, `apply` won't touch `src/` — it +writes the proposed reconciliation backlog to `.rig/plan.md` (e.g. *add +`DELETE /notes/{id}`*, *decide `PUT` vs `PATCH`*, *spec `GET /health` or remove +it*). Switch `sync.apply.sink` to `backlog` (with a tracker) or `workflow` (with +Smithers) to route the same drift to a milestone or an ephemeral workflow. + +## Peek under the hood + +The extractor is the only demo-specific piece — run it directly to see the +actual-state JSON rig-sync consumes: + +``` +.rig/rig-sync-extractor +``` + +To change the drift, edit `src/app.js` (add the `DELETE` handler, rename `PATCH` +to `PUT`, remove `/health`) and re-run `/rig-sync plan` — it should come back +clean. diff --git a/demos/notes-api/SPEC.md b/demos/notes-api/SPEC.md new file mode 100644 index 0000000..02f4e0f --- /dev/null +++ b/demos/notes-api/SPEC.md @@ -0,0 +1,29 @@ +# SPEC — Notes API + +A tiny HTTP service for notes. This spec is the **desired state**: the code in +`src/` is the **actual state**, and `rig-sync` reconciles the two. + +## API surface + +The service exposes exactly these endpoints. Each row is one surface element +`METHOD /path`; `{id}` is a path parameter. + +| Method | Path | Purpose | +|--------|---------------|-----------------------------| +| GET | /notes | List all notes | +| POST | /notes | Create a note | +| GET | /notes/{id} | Fetch one note by id | +| PUT | /notes/{id} | Replace a note by id | +| DELETE | /notes/{id} | Delete a note by id | + +## Invariants + +- Every endpoint in this spec has a handler in `src/`. +- No **undocumented** endpoints: every route the code serves appears in the + table above. (A health/metrics endpoint, if wanted, must be specced first.) + +## Notes (the human kind) + +This is deliberately small so the drift is easy to see. The code under `src/` +was written to *almost* match this spec — three things are out of sync. Run +`/rig-sync plan` to find them without reading the code first. diff --git a/demos/notes-api/src/app.js b/demos/notes-api/src/app.js new file mode 100644 index 0000000..2efc699 --- /dev/null +++ b/demos/notes-api/src/app.js @@ -0,0 +1,51 @@ +// Notes API — the ACTUAL state. Compare against ../SPEC.md. +// +// An Express-style router. rig-sync doesn't run this; the extractor +// (.rig/rig-sync-extractor) reads the route declarations below as the code's +// actual surface. Three things drift from the spec on purpose. + +const express = require("express"); +const app = express(); +app.use(express.json()); + +const notes = new Map(); +let nextId = 1; + +// GET /notes — list all notes +app.get("/notes", (req, res) => { + res.json([...notes.values()]); +}); + +// POST /notes — create a note +app.post("/notes", (req, res) => { + const id = String(nextId++); + const note = { id, body: req.body.body ?? "" }; + notes.set(id, note); + res.status(201).json(note); +}); + +// GET /notes/:id — fetch one note +app.get("/notes/:id", (req, res) => { + const note = notes.get(req.params.id); + if (!note) return res.status(404).json({ error: "not found" }); + res.json(note); +}); + +// PATCH /notes/:id — partially update a note +// (the spec asks for PUT /notes/{id} — a full replace. Method drift.) +app.patch("/notes/:id", (req, res) => { + const note = notes.get(req.params.id); + if (!note) return res.status(404).json({ error: "not found" }); + if (req.body.body !== undefined) note.body = req.body.body; + res.json(note); +}); + +// GET /health — liveness probe +// (not in the spec at all — an undocumented endpoint.) +app.get("/health", (req, res) => { + res.json({ ok: true }); +}); + +// NOTE: there is no DELETE /notes/:id handler — the spec asks for one. + +module.exports = app; From 56455002d3afe195e45506c10979fb66318fa714 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:42:26 +0000 Subject: [PATCH 09/18] feat(rig-sync): SPEC + drift engine + report (M0 T1,T3,T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC.md for rig-sync (plan/apply, durable Smithers workflow sink, extractor adapter) — the backlog on agent-rig board #1. - T1: sync.* config block in schema + example. - T3: scripts/rig-sync.ts computeDrift — directional bidirectional diff by (kind,id): missing / undocumented / diverged / aligned + carried invariants. 16 bun tests; reproduces the notes-api demo drift from the real extractor. - T4: renderReport + 'report' verb -> terraform-plan-style .rig/DRIFT.md. - Dogfood: .rig/config.json points rig at agent-rig board #1. Refs #46 #48 #49 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- .rig/config.json | 46 ++++++++++++ demos/notes-api/.gitignore | 4 ++ rig.config.example.json | 7 ++ rig.schema.json | 21 ++++++ scripts/rig-sync.test.ts | 139 +++++++++++++++++++++++++++++++++++++ scripts/rig-sync.ts | Bin 0 -> 9212 bytes skills/rig-sync/SPEC.md | 113 ++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+) create mode 100644 .rig/config.json create mode 100644 scripts/rig-sync.test.ts create mode 100644 scripts/rig-sync.ts create mode 100644 skills/rig-sync/SPEC.md diff --git a/.rig/config.json b/.rig/config.json new file mode 100644 index 0000000..8bcbab4 --- /dev/null +++ b/.rig/config.json @@ -0,0 +1,46 @@ +{ + "$schema": "../rig.schema.json", + "project": { + "name": "rig", + "repo": "agent-rig/rig" + }, + "runtime": { + "packageManager": "bun" + }, + "test": { + "command": "bun test", + "requiresDatabase": false + }, + "sourceScope": ["skills", "scripts", "smithers", "docs"], + "vcs": { + "defaultBranch": "main", + "baseRef": "origin/main", + "branchConvention": "{user}/{ticket}-{slug}", + "protectedBranchMergeQueue": false + }, + "tracker": { + "provider": "github", + "team": "agent-rig", + "githubIntegration": false, + "shapeLabels": { "epic": "epic", "sprint": "sprint" }, + "board": { + "owner": "agent-rig", + "projectNumber": 1, + "statusField": "Status", + "statusOptions": { "todo": "Todo", "inProgress": "In Progress", "done": "Done" }, + "closingKeyword": "Closes" + } + }, + "review": { + "patternsFile": ".claude/REVIEWER.md", + "bot": "none", + "maxRounds": 5 + }, + "agents": { + "architect": "rig-architect", + "coder": "rig-coder", + "reviewer": "rig-reviewer", + "qa": "rig-qa", + "debugger": "rig-debugger" + } +} diff --git a/demos/notes-api/.gitignore b/demos/notes-api/.gitignore index 0873661..f5eac32 100644 --- a/demos/notes-api/.gitignore +++ b/demos/notes-api/.gitignore @@ -3,3 +3,7 @@ .rig/plan.md .rig/spec.lock.json node_modules/ + +# smithers scaffold (generate on demand with `smithers init`; never committed) +.smithers/ +smithers.db* diff --git a/rig.config.example.json b/rig.config.example.json index 7f2b6d3..eeed9cc 100644 --- a/rig.config.example.json +++ b/rig.config.example.json @@ -46,5 +46,12 @@ "imageRegistry": "ghcr.io", "slackWebhookSecret": "SLACK_CI_WEBHOOK_URL", "trustBoundaryPaths": ["packages/api/src/auth", "packages/api/src/billing", "infra/"] + }, + "sync": { + "specGlob": "SPEC.md", + "extractor": ".rig/rig-sync-extractor", + "truth": "ask", + "driftReport": ".rig/DRIFT.md", + "apply": { "sink": "workflow" } } } diff --git a/rig.schema.json b/rig.schema.json index 8eca3c3..75a5d14 100644 --- a/rig.schema.json +++ b/rig.schema.json @@ -120,6 +120,27 @@ "description": "Paths that trip the deeper security-scan gate (auth, secrets, tenancy, billing surfaces)." } } + }, + "sync": { + "type": "object", + "description": "rig-sync (spec <-> code reconciliation) configuration. Consumed by rig-sync plan/apply. Absent = rig-sync is unconfigured for this project.", + "additionalProperties": false, + "properties": { + "specGlob": { "type": "string", "default": "SPEC.md", "description": "Desired-state source: a spec/catalog file or glob. Defaults to the first of SPEC.md, specs/prd.md, docs/prd.md that exists." }, + "projection": { "type": "string", "description": "Optional path to a generated machine-readable projection of the spec (the diffable middle layer, e.g. .rig/spec.lock.json). When set, rig-sync regenerates and diffs it; unset, it diffs the prose spec directly." }, + "extractor": { "type": "string", "description": "Project-supplied executable that prints the code's actual surface as JSON {surface,invariants}. Resolver: .rig/rig-sync-extractor if executable, else this value, else a best-effort agent scan." }, + "preserve": { "type": "array", "items": { "type": "string" }, "default": [], "description": "Globs of hand-maintained files/regions in the projection that are never regenerated — preserved verbatim; rig-sync only cross-checks them and flags contradictions." }, + "truth": { "type": "string", "enum": ["spec", "code", "ask"], "default": "ask", "description": "Direction of truth when spec and code disagree: spec (code drift becomes work), code (spec drift becomes a doc update), or ask (report both, human decides)." }, + "driftReport": { "type": "string", "default": ".rig/DRIFT.md", "description": "Where the human-readable drift report is written." }, + "apply": { + "type": "object", + "additionalProperties": false, + "description": "How the apply verb reconciles drift.", + "properties": { + "sink": { "type": "string", "enum": ["workflow", "backlog", "report"], "default": "workflow", "description": "workflow: run the durable Smithers reconcile workflow with the drift as input (default; multi-modal, resumable). backlog: one milestone + tickets via rig-plan. report: write the drift-spec to a file only." } + } + } + } } } } diff --git a/scripts/rig-sync.test.ts b/scripts/rig-sync.test.ts new file mode 100644 index 0000000..fdc9bb7 --- /dev/null +++ b/scripts/rig-sync.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { computeDrift, hasDrift, parseSurfaceDoc, renderReport, type SurfaceEl } from "./rig-sync.ts"; + +const ep = (id: string, extra: Partial = {}): SurfaceEl => ({ kind: "endpoint", id, ...extra }); + +describe("computeDrift — classification", () => { + test("identical surfaces are all aligned, no drift", () => { + const s = [ep("GET /notes"), ep("POST /notes")]; + const d = computeDrift(s, s); + expect(d.summary).toMatchObject({ aligned: 2, missing: 0, undocumented: 0, diverged: 0 }); + expect(hasDrift(d)).toBe(false); + }); + + test("in spec, not in code -> missing (work)", () => { + const d = computeDrift([ep("GET /notes"), ep("DELETE /notes/{id}")], [ep("GET /notes")]); + expect(d.missing.map((m) => m.id)).toEqual(["DELETE /notes/{id}"]); + expect(d.undocumented).toHaveLength(0); + expect(hasDrift(d)).toBe(true); + }); + + test("in code, not in spec -> undocumented (doc)", () => { + const d = computeDrift([ep("GET /notes")], [ep("GET /notes"), ep("GET /health")]); + expect(d.undocumented.map((u) => u.id)).toEqual(["GET /health"]); + expect(d.missing).toHaveLength(0); + }); + + test("same id, differing attrs -> diverged with changed=['attrs']", () => { + const d = computeDrift( + [ep("PUT /notes/{id}", { attrs: { semantics: "replace" } })], + [ep("PUT /notes/{id}", { attrs: { semantics: "merge" } })], + ); + expect(d.diverged).toHaveLength(1); + expect(d.diverged[0]!.changed).toEqual(["attrs"]); + expect(d.aligned).toHaveLength(0); + }); + + test("same id, differing role -> diverged with changed=['role']", () => { + const d = computeDrift( + [{ kind: "topic", id: "orders.filled", role: "producer" }], + [{ kind: "topic", id: "orders.filled", role: "consumer" }], + ); + expect(d.diverged[0]!.changed).toEqual(["role"]); + }); + + test("owner/ref are location metadata, ignored in equality", () => { + const d = computeDrift( + [ep("GET /notes", { owner: "spec", ref: "SPEC.md:5" })], + [ep("GET /notes", { owner: "src", ref: "src/app.js:15" })], + ); + expect(d.summary).toMatchObject({ aligned: 1, diverged: 0 }); + }); + + test("directional: extra fields the spec doesn't declare are NOT divergence", () => { + // spec just requires the endpoint exists; code adds role/owner/ref/extra attrs + const d = computeDrift( + [ep("GET /notes")], + [ep("GET /notes", { role: "route", owner: "src", ref: "src/app.js:15", attrs: { auth: true } })], + ); + expect(d.summary).toMatchObject({ aligned: 1, diverged: 0 }); + }); + + test("directional: spec declaring a field the code omits/mismatches IS divergence", () => { + const d = computeDrift([ep("GET /notes", { attrs: { auth: true } })], [ep("GET /notes", { attrs: { auth: false } })]); + expect(d.diverged[0]!.changed).toEqual(["attrs"]); + }); + + test("empty vs empty is clean", () => { + expect(hasDrift(computeDrift([], []))).toBe(false); + }); + + test("everything missing when code is empty", () => { + const d = computeDrift([ep("GET /notes"), ep("POST /notes")], []); + expect(d.summary).toMatchObject({ missing: 2, undocumented: 0 }); + }); +}); + +describe("computeDrift — notes-api reference shape", () => { + const spec = ["GET /notes", "POST /notes", "GET /notes/{id}", "PUT /notes/{id}", "DELETE /notes/{id}"].map((id) => + ep(id), + ); + const code = ["GET /notes", "POST /notes", "GET /notes/{id}", "PATCH /notes/{id}", "GET /health"].map((id) => ep(id)); + + test("reproduces the demo's drift: 3 aligned, PUT+DELETE missing, PATCH+/health undocumented", () => { + const d = computeDrift(spec, code); + expect(d.summary).toMatchObject({ aligned: 3, missing: 2, undocumented: 2, diverged: 0 }); + expect(d.missing.map((m) => m.id)).toEqual(["DELETE /notes/{id}", "PUT /notes/{id}"]); + expect(d.undocumented.map((u) => u.id)).toEqual(["GET /health", "PATCH /notes/{id}"]); + }); +}); + +describe("invariants + summary", () => { + test("declared invariants are carried through unchanged", () => { + const inv = [{ assert: "every topic has exactly one producer" }]; + const d = computeDrift([ep("GET /x")], [ep("GET /x")], inv); + expect(d.invariants).toEqual(inv); + expect(d.summary.invariants).toBe(1); + }); +}); + +describe("renderReport", () => { + const spec = ["GET /notes", "PUT /notes/{id}", "DELETE /notes/{id}"].map((id) => ep(id)); + const code = [ep("GET /notes"), ep("PATCH /notes/{id}", { ref: "src/app.js:36" }), ep("GET /health", { ref: "src/app.js:45" })]; + + test("renders each drift class with counts", () => { + const md = renderReport(computeDrift(spec, code, [{ assert: "no undocumented endpoints" }]), { + project: "notes-api", + truth: "ask", + }); + expect(md).toContain("# DRIFT — notes-api"); + expect(md).toContain("## Missing"); + expect(md).toContain("`DELETE /notes/{id}`"); + expect(md).toContain("## Undocumented"); + expect(md).toContain("`GET /health` `src/app.js:45`"); // ref surfaced + expect(md).toContain("## Invariants"); + expect(md).toContain("truth: `ask`"); + }); + + test("says 'In sync' when there is no drift", () => { + const md = renderReport(computeDrift([ep("GET /x")], [ep("GET /x")])); + expect(md).toContain("In sync"); + expect(md).not.toContain("## Missing"); + }); +}); + +describe("parseSurfaceDoc", () => { + test("accepts a bare surface array", () => { + const { surface, invariants } = parseSurfaceDoc('[{"kind":"endpoint","id":"GET /x"}]'); + expect(surface).toHaveLength(1); + expect(invariants).toEqual([]); + }); + + test("accepts a { surface, invariants } envelope", () => { + const { surface, invariants } = parseSurfaceDoc( + '{"surface":[{"kind":"endpoint","id":"GET /x"}],"invariants":[{"assert":"a"}]}', + ); + expect(surface).toHaveLength(1); + expect(invariants).toEqual([{ assert: "a" }]); + }); +}); diff --git a/scripts/rig-sync.ts b/scripts/rig-sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b454c71ee63540e62f62879e8597421a1d66fb4 GIT binary patch literal 9212 zcmcIq+in}l5zVu{qD%{$87(=q_mR3mw(NxhJG<}&d5{Tk$Z1Nm;><8}p+ryw@{|t< z@-KPMr|g&HR8{v}C<@su5CgU}J>7LZb?F+vd@-zwd^nBMp-ERXtyMH$a1%Hw%SCcTMML-Q=JkC0a^&5TY4XEe(q;|HME zCP`&(LXZtf_JsvqzWaRj>D?t2Wmp;t(?|{y-dw!>^7Z0_z6j`x2vNkjiI`wf<@0c6 zC@yH3XHhjX5zX@~5n+Y4H(i4qg*PTG`ve(7!mlO=n~2Z+VrFHWrT#IKV->?$S%r(e zltGGj*i(Nj$UKxe_A}KeT*5R=#0y}#p|jqg*#m3*>GMy2rj3=lPjR{m^Egb)V(YVR zP$PjB3m7^x1v6uU8LYbu^AgWtNl8_dG~IE5aTn1bFv( zT=Eh-dl+T2!soN_u7TXJbf4MZh{=o6ECo&}bA_Hz?$+eSvHe=WRph<>aOIEq6xE<& z97gecPJ=;Y1Ob4f{0x36vJ}hKb^1dWuLlEuIc8FCvhtSZ@dB0&b3-PEa*T&5KTWQK zAQ(@a>98XY*Nm6R#;vveMcWBWOz$4zQlZKUFYd#2L9dQ&WMC-@%dk&rR)Wtwj29I& zC2ukSEaGy&_L@)<-a(|eWO}?zuj&5Q0JG_uk{B*ZZ-!M0vsUaM6Ahynrn`Y(Z2&$X z9N}w@z_(XyXPgLpkQ*Kxn1^M?&{;1Heb#7sx1dwn91t-cMu@B&`+1brcPS2^9Up9uyAoZw0APs_?S;up$C7q8Uvj#+ zDoyg6#JAXhG_~5Sqk?016)8{5S6em)n_>h%#IsxYH(?x45tsAWEF#4ru;caENIiO_ zo~YjQ+2%yE+c1THYTI0opQy4-e02F4$W{A5O$S!a5thzzC0@Iv>a+`Pz;EDuNW)>_US5!$7=Iapy$k{Qi zlBvm$8w0w9u2I}-qncZ3%cyG%M@A$PAQCY0DtzY|=GqCPRuIwFr_(c<9Bxd&d$xbz z-{NRH0U1Wz=Ta~wMS&HK3{+FY#hlT+Y|7<{nH5wZjA0lUqRI=nVzEZa0NUnNDiw?? zBoOMYMlRm`I+8fp<2iXNIz2t5v|23a@lkR@Hm4Q6z+z8%0l~Q`t2~ub6=(#bMtCc( zkV52wGh+}J@)PUziOKchW)&pi(o<3V;Fl~;ydNFQIC0t?NQtYPV{)2F|{NtkwLhBiqN!(r_f!%xyveF zW(BIj7Z8xZlak4WKE@81N_vYC3WM4$59738l3BV~BQt?FUO~0e6aigmY7%jTT9(8p zm>ysy9SFB>5KG?IEeTT#Bjhudk5d^sBu?^u*ni%)2PLyfLD!NUx=fJHAieA#VmsGV zrnksuC_qp!0Yff-(re%)>2bxkiMV{W(Cb3*y(C!AX75(=My8U^thH1F{CX_)OyOmd zuLD2cosyTeLt6B(t4g0&5t!B#R$yV(6Nznj z+4m0p^cID#LjSFO#nhDdhWikwk!yt1qj=G2l>_=&x*K@aHXU_pVxNPqR5uT%cKFACV%QS0r7)naIr7$SIydH*8?7 zmWxEK&SC4U=g-!x>k`x|gdjvZkc+Xl#7~uv>*}}6;GL7-}NaP6Y{+^ z#GAD*LE~%|LIhRw%{sU-B~mJzM05U&&~2`p5(Sm3evwo-zFdqbLX876^#LAH2bIzm zcZ|YVu8yz8Ahe*AaG4WYQf+1eSFBmYJ1o8UGq-~qqW#dXCBTP6?1^-!Q(kC=cHMS%oyay1X6GDBD=$rt=9adz(Mg4PuzmE-hDK17e0MD>I-9mE6t|(;K7Pq7qc=tLAgHh0KcwsOmT%%{ zkn5IN+Qi+I419^}J$LIs8l7e+a~PJe&8_*Pz&X@L-S-IZJ!%rK?`TG6xC*ndnt{+5 zFar5bS>MPMbmlzZ2I8D1^i8^DJZ-;CCV*Ox>yz(y++7|<|o-8b%{yam|$CH%MzEsjhpz?_qk(f z*nV7O)7Da%SLH39d)$7>oschBdO0ladbEZ18@RCUT3S7eoW*-)80Z7~hF*wsoI2Hj zANI5~A2cUSUee{KtM^|HH$uTqsKlb&+I5$=YS`_hujVDPRKSU7@@?W~SH$LIj|fp# zi&Yk?|NfWutWVz7e%liEh$WyP&vy_YY68M)^5 zSZMrx#HD+dRk-^;%Hv=#c;vs@-#D{8x~Rr(Tmu3d<6C;12xwKYNRd8R73*3mH&b~ARd>zAD zN>N5Tu&pS=9T=1m_DUYMOQCTT*dFEF-OUM?^TQ2tvlj*0KkyBiV^4b*Rovz$o)d6R zs?8{&h!Ka4bG%cgGVrX*Yyhuii4D>iC|7;TA@Ydn-1^BZaZ;!zDmlVwT~`A_{9wv(;np zWU*1UGjc>t=G3PJ2*WWQdI=AqEOEd{e0Jv5Vz_GSF~Z1!TeZ58YHvh^)6Z6@@wnl3 zq+11ztR(MU$2Y_mj=_NNWki1Ahu{0B85)N9%};X=|6**Y!#)-$4HHZ@^|@(R9=X4@ zvy#SaG0ev2A$MdRK#UKI;VejEI>SW`T?6mBj?pQ;9;36XX|-C!ub(e|d`G9STCd{o zKqCN)zUN2SvGqApJ_8ZVVDC$G!k=}{u%Ic-J>%<7{hej}3@vRBe-kYX#4Y79fwU$? z&Lxjrlpc}&WZiXNCv1r`S(1cva)a*aY>bZXAms8&^?XEH+V}Wwx#J1g{%5Slg*lOC z{j3F7tNPC&UxnGW){m(5wOusTD7@7&`?u>m-OX@!_fXl5N%4#3uq!@H6t{%{t#_7V z80JcJ&D;5azvb@uMHvyd)iYBR0sF}NK`_IWR^{dE>Ta}$_aR1}Z>lt4f4h+mOX&xk z3B1l2=f4wZsntG@?+^}Hig*6f3pexpy@}3ae9*%neHx;!;kw0h;Cir%Z^N)-ijSKZ zb6SO(lMoCQx=!adg!FdRMMHIMJ}0OtkL}1oYTC#gSP~upoQ-| zQ^nNk`qh{@GbnVGTL2C}^Z=X#oN{_80}jPQT|A3p8*dk7gg^@To3ZhB#`SU#`3GMp z`t*a3T+!3o!kkzLn`vf?3Lf%+^g6yF5C*tUTH6lBURzN#GqGZ>vul*oeDJ@kr>IRB zww-IGRY{E{R^V?saBeqZZPMuZdOgBtEdY+MTTgcKi)Necyx1`91R%>$0)6r`_fem# zi*$3!(GBTT4^DMYvI-Vm=&~>H=sykc;zZ^V{CNZ)%i>gR zC?V;N2+E5^JTpj(et-`uvs(|7Y93lQQP-IBvwXtGXeCIHPx(Jkz_&(|VH0?@Ab`E3 Gh5rDY;G|gq literal 0 HcmV?d00001 diff --git a/skills/rig-sync/SPEC.md b/skills/rig-sync/SPEC.md new file mode 100644 index 0000000..8390269 --- /dev/null +++ b/skills/rig-sync/SPEC.md @@ -0,0 +1,113 @@ +# SPEC — rig-sync (spec ⇄ code reconciliation) + +## Goal + +`rig-sync` keeps a repo in sync with its spec — the **terraform loop for code**. +Treat the spec as **desired state** and the code as **actual state**; compute the +**drift** between them (`plan`) and **reconcile** it (`apply`). + +The reconciliation *executes on a durable, engine-agnostic substrate* (Smithers) +so that: + +- a **large reconciliation can run for hours or days**, survive a crash, and + **resume** from the last completed step; +- it is **multi-modal** — worker seats can be any agent engine (claude, codex, + kimi, …), chosen by the *target project's* config, never hardcoded by rig. + +rig-sync produces the workflow **cheaply** (a parameterized workflow, drift as +input — authored directly, *not* via an interactive workflow-authoring assistant) +and hands it to the durable engine to run. + +## Non-goals + +- **Never edits product code directly.** Every code change flows through a gated + build loop (RED → GREEN → review), same as `rig-task`. +- **No per-run workflow *authoring*.** The reconcile workflow is authored **once** + and parameterized by the drift. rig-sync must not invoke a 40-minute + `make-workflow`-style authoring pipeline on every `apply`. +- **No engine/runtime lock-in.** rig-sync itself picks no model; the durable + engine + the project's agent config decide. + +## Model + +`plan` (read-only) → `apply` (reconcile via a **pluggable sink**). Direction of +truth is a human call (`sync.truth`: `spec` | `code` | `ask`, default `ask`). + +## Milestones + +### Milestone 0 — `plan`: read-only drift *(foundation — start here)* + +Small, pure, unit-testable. No execution, no writes to product code. + +- **T1 · Config.** Add a `sync` block to `rig.schema.json` + `rig.config.example.json`: + `specGlob`, `projection?`, `extractor`, `preserve[]`, `truth`, `apply.sink`, + `driftReport`. Schema-validated; `rig-doctor` recognizes it. +- **T2 · Extractor adapter.** A project-supplied executable that enumerates the + code's actual surface as JSON `{ surface:[{kind,id,role,owner,ref}], invariants:[{assert}] }`. + Resolver: `.rig/rig-sync-extractor` if executable, else `sync.extractor`, else + best-effort agent scan. Ship the contract doc + a reference extractor + tests. + (Same adapter pattern as `rig-tracker`.) +- **T3 · Drift engine.** Diff desired surface (from spec/projection) vs actual + (extractor), keyed by `(kind,id)` → classify **missing / undocumented / + diverged**; check declared `invariants`. Pure function, unit-tested both ways. +- **T4 · Drift report.** Write `sync.driftReport` (`.rig/DRIFT.md`, terraform-plan + style: counts per class, invariant violations, truth verdict per diverged item) + + a printed summary. +- **T5 · `plan` verb.** Wire the SKILL: resolve config/spec/extractor → build + desired → run extractor → diff → report → **STOP** (read-only, like + `rig-review find`). + +### Milestone 1 — `apply`: `report` + `backlog` sinks + +Reconciliation that reuses existing rig machinery; no new runtime. + +- **T6 · Drift → drift-spec.** Split drift into *work* (missing + spec-winning + diverged) and *doc* (undocumented + code-winning diverged); synthesize a scoped + reconciling spec. +- **T7 · `report` sink.** Write the drift-spec + proposed units to `.rig/plan.md`. + The always-available rung. +- **T8 · `backlog` sink.** Create **one milestone** (`reconcile → drift vN`) + and hand the drift-spec to `/rig-plan` so units land as grouped tickets on the + board. For drift that needs human scheduling / cross-team visibility. + +### Milestone 2 — `apply`: `workflow` sink (durable, multi-modal) *(epic)* + +The durable execution path — the reason we use Smithers. One **parameterized** +reconcile workflow, drift as input; `apply --sink workflow` runs it. + +- **T9 · `smithers/workflows/rig-sync.tsx`.** A parameterized reconcile workflow + taking a `driftReport` input. Shape (validated by the make-workflow spike): + `verify-drift` (re-run extractor; self-healed units short-circuit) → **test-runner + scaffold** (bootstrap a runner if the repo has none, pre-fork on trunk) → + per-unit `` + `` lanes (RED→GREEN for code units, review-only + for doc units; escalate on max iterations) → **plan gate** + **merge gate** + (``, subset approval) → `` (rebase → merge → test, with a + merge-fix loop) → **final-verify** (re-run extractor vs spec ⇒ **zero residual + drift**) → report. Must render under `smithers graph`. +- **T10 · `workflow` sink wiring.** `apply --sink workflow` composes drift → input + and runs it (`smithers up`), reports the run id + how to monitor, and degrades + to `backlog` when Smithers is absent (say so). +- **T11 · Engine-agnostic docs.** Document that worker model tiering comes from the + *target project's* Smithers `agents.ts` / accounts — rig picks nothing. + +### Milestone 3 — projection layer *(optional)* + +- **T12 · Machine projection.** Generate a diffable `sync.projection` (the + terraform state-file analogue) from the spec, diff *it* vs code, and preserve + `sync.preserve` regions verbatim. + +## Success criteria + +1. `rig-sync plan` on a drifted repo produces an accurate **bidirectional** + `DRIFT.md` (missing / undocumented / diverged + invariant checks). +2. `rig-sync apply --sink report` writes a correct reconciling drift-spec; touches + no product code. +3. `rig-sync apply --sink workflow` runs the **durable** reconcile workflow with + the drift as input and (given a model account) reconciles to **zero residual + drift**, **resumable across a crash**. +4. No product code is ever edited outside the gated build loops. +5. rig-sync runs the same regardless of agent engine — **multi-modal, no lock-in**. + +> Reference sandbox: `demos/notes-api` (spec + drifted code + a working extractor). +> `make-workflow` was a spike to discover the workflow *shape*; the shipped sink +> **runs** the parameterized workflow, it does not author one per run. From 976c7b3a81b4709faf00a9e89d894f955721cdde Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:03:50 +0000 Subject: [PATCH 10/18] feat(rig-sync): extractor validator + contract doc + plan-verb wiring (M0 T2,T5) - T2: validateSurfaceDoc + 'validate-extractor' verb (rejects malformed adapter output with exit 1); docs/rig-sync.md contract + reference extractor; validated against the real notes-api extractor. 23 bun tests. - T5: SKILL plan verb wired to scripts/rig-sync.ts (extractor resolver -> validate -> desired surface -> diff/report -> STOP). Corrected the apply/ workflow wording to the durable, parameterized Smithers workflow model (run once, drift as input; never make-workflow; engine-agnostic). M0 complete (T1-T5). Refs #47 #50 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- docs/rig-sync.md | 91 +++++++++++++++ scripts/rig-sync.test.ts | 48 +++++++- scripts/rig-sync.ts | Bin 9212 -> 11809 bytes skills/rig-sync/SKILL.md | 247 ++++++++++++++------------------------- 4 files changed, 226 insertions(+), 160 deletions(-) create mode 100644 docs/rig-sync.md diff --git a/docs/rig-sync.md b/docs/rig-sync.md new file mode 100644 index 0000000..62af34c --- /dev/null +++ b/docs/rig-sync.md @@ -0,0 +1,91 @@ +# rig-sync — spec ⇄ code reconciliation + +`rig-sync` keeps a repo in sync with its spec — the **terraform loop for code**. +The spec is **desired state**, the code is **actual state**; `plan` computes the +**drift** and `apply` reconciles it. `plan` is read-only; `apply` never edits +product code directly (it routes drift to a gated sink). + +## The pieces + +| Piece | What it is | +|---|---| +| **desired surface** | what the spec requires, as structured elements. Produced from the spec by an agent (or a generated `sync.projection`). | +| **actual surface** | what the code actually exposes. Produced by the project's **extractor adapter**. | +| **drift engine** | `scripts/rig-sync.ts` — the deterministic diff of desired vs actual. | +| **sinks** | how `apply` reconciles: a durable Smithers workflow, a tracker backlog, or a report. | + +## The extractor adapter + +Drift only generalises if rig-sync doesn't hardcode what "a surface" is. The +**project owns that**, exactly like the `rig-tracker` adapter owns "a board." The +extractor is any executable that prints this JSON to stdout: + +```json +{ + "surface": [ + { "kind": "endpoint", "id": "GET /notes/{id}", "role": "route", + "owner": "src", "ref": "src/app.js:28" } + ], + "invariants": [ + { "assert": "every endpoint in SPEC.md has a handler in src/" } + ] +} +``` + +- **`kind` + `id`** are the identity; `(kind,id)` must be **unique**. `kind` is + whatever the project models — `endpoint`, `topic`, `rpc`, `table`, `flag`, + `cli-command`, … +- **`role`** and **`attrs`** (an optional object) are the *comparable* fields: + when the same `(kind,id)` exists on both sides but the spec declares a `role` + or `attrs` the code doesn't match, that's a **divergence**. The comparison is + **directional** — the spec is a partial contract, so extra detail the code + carries (extra `attrs`, `role`, `owner`, `ref`) is *never* a divergence. +- **`owner`** / **`ref`** are location metadata (for the report); never compared. +- **`invariants`** are project-declared assertions carried through to the + reconcile step (the drift engine reports them; it does not evaluate free text). + +**Resolver** (highest first): `.rig/rig-sync-extractor` if executable → the +`sync.extractor` config value → a best-effort agent scan of `sourceScope` (marked +*best-effort* in the report). + +**Validate** an extractor before wiring it in: + +``` +bun scripts/rig-sync.ts validate-extractor .rig/actual.json # or pipe on stdin +``` + +A working reference extractor (HTTP routes, ~40 lines of Python, grep-based) +lives at [`demos/notes-api/.rig/rig-sync-extractor`](../demos/notes-api/.rig/rig-sync-extractor). + +## The drift engine + +`scripts/rig-sync.ts` (Bun) diffs two surface docs and classifies every element: + +- **missing** — in the spec, absent from the code → *work*. +- **undocumented** — in the code, absent from the spec → *spec/doc* (or out of scope). +- **diverged** — present both sides, a spec-declared field differs → *decision* (resolved by `sync.truth`). +- **aligned** — present both sides, code satisfies the spec. + +``` +bun scripts/rig-sync.ts diff --desired desired.json --actual actual.json +bun scripts/rig-sync.ts report --desired desired.json --actual actual.json --out .rig/DRIFT.md --truth ask +``` + +## Sinks (`apply`) + +Chosen by `sync.apply.sink`: + +- **`workflow`** (default) — run the **durable Smithers reconcile workflow** with + the drift as input. It survives crashes, resumes over days, and is + **multi-modal**: worker seats come from the *target project's* Smithers + `agents.ts` / accounts — **rig picks no model or engine.** rig-sync ships the + workflow (authored once, parameterized by drift); it does **not** author a new + workflow per run. +- **`backlog`** — one milestone + tickets via `/rig-plan` (board-native). +- **`report`** — write the drift-spec to a file only. + +## Configuration + +See the `sync` block in [`rig.schema.json`](../rig.schema.json) / +[`rig.config.example.json`](../rig.config.example.json): `specGlob`, `projection?`, +`extractor`, `preserve[]`, `truth`, `driftReport`, `apply.sink`. diff --git a/scripts/rig-sync.test.ts b/scripts/rig-sync.test.ts index fdc9bb7..c3a4adb 100644 --- a/scripts/rig-sync.test.ts +++ b/scripts/rig-sync.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { computeDrift, hasDrift, parseSurfaceDoc, renderReport, type SurfaceEl } from "./rig-sync.ts"; +import { + computeDrift, + hasDrift, + parseSurfaceDoc, + renderReport, + validateSurfaceDoc, + type SurfaceEl, +} from "./rig-sync.ts"; const ep = (id: string, extra: Partial = {}): SurfaceEl => ({ kind: "endpoint", id, ...extra }); @@ -122,6 +129,45 @@ describe("renderReport", () => { }); }); +describe("validateSurfaceDoc — adapter contract", () => { + test("a valid envelope passes", () => { + const r = validateSurfaceDoc('{"surface":[{"kind":"endpoint","id":"GET /x"}],"invariants":[{"assert":"a"}]}'); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + }); + + test("a bare array passes", () => { + expect(validateSurfaceDoc('[{"kind":"topic","id":"o.filled"}]').ok).toBe(true); + }); + + test("non-JSON fails cleanly", () => { + const r = validateSurfaceDoc("not json {"); + expect(r.ok).toBe(false); + expect(r.errors[0]).toContain("not JSON"); + }); + + test("missing surface array fails", () => { + expect(validateSurfaceDoc('{"invariants":[]}').ok).toBe(false); + }); + + test("element missing kind/id is reported with index", () => { + const r = validateSurfaceDoc('{"surface":[{"id":"GET /x"},{"kind":"endpoint"}]}'); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.includes("surface[0]") && e.includes("kind"))).toBe(true); + expect(r.errors.some((e) => e.includes("surface[1]") && e.includes("id"))).toBe(true); + }); + + test("duplicate (kind,id) is rejected", () => { + const r = validateSurfaceDoc('{"surface":[{"kind":"endpoint","id":"GET /x"},{"kind":"endpoint","id":"GET /x"}]}'); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.includes("duplicate"))).toBe(true); + }); + + test("invariant without assert is rejected", () => { + expect(validateSurfaceDoc('{"surface":[],"invariants":[{"note":"x"}]}').ok).toBe(false); + }); +}); + describe("parseSurfaceDoc", () => { test("accepts a bare surface array", () => { const { surface, invariants } = parseSurfaceDoc('[{"kind":"endpoint","id":"GET /x"}]'); diff --git a/scripts/rig-sync.ts b/scripts/rig-sync.ts index 9b454c71ee63540e62f62879e8597421a1d66fb4..9a7860fce43f178b4834c1c4312deafb76564db0 100644 GIT binary patch delta 2541 zcmaJ@O^+Kz5LJ{DD};muLgYiKj8@4^ut($;Iq_N`HVBYd$VzbVTAJRO^19=3&(PiD z&C0Qc8xmJ@MuH;@lp$K5qm^bt!Ah!l=Q_;kiDga za%Md!g*GTobA&ICkG}TaozqjH5lp35&u_d_CkuWYixfNjx>R$arBK$uGI@6^zPaL&%2 zVOr?yi7#!Z#d6?M_3#_fkgu@yJBZuE!2nd1=MlTw;z5ofyt^B^c_VVDnW6uM9n7RL z+#E^sUIK!VP!2CjFNtIr$MGnELc>PdJ#eQa=_t6wi$PQbL*aQ1$7mbS2cZMo#EGc~ z4@Ei&L(DrMBj_JAWopDg4lrl9-w17`$e$d0ksam-Tm=BeZZAqK#(;yEn*#3k`w)1F z19V2(cx~^7sNWbv zW^an%ko32a+`9)Gpp?M&Wh?HsiKfI`vcK@Smo6^f;q9Joit*si3pHE1qrA9*&KX4f@39rFM zh~dWlR@+h18|{+Cu6TFk!VW;x2EJ%_Ks1Q`{JktyZ@g{Wm~JY(i_U@b*xslz4)#>r zjnt?#P6)n0{Jb+B8uoHjXLjOr_HjESU;Tdb#SQY#AunU-Vw>gU@(UB4oUpe51|m{Kr8Qo`AM_;BRJ)jY?>(#SF2OFIdJ zZkOI0tj&7-bTkgxv;%a@xV(1S4&)68b)SAMPyl-KTu2L_Rw`!GkWZtSZNi9_gTJLo zNejZ1v<5L`Q%4W|)hK3R)Y|+0^6B$pZA(MDuG!SUzK}M27`^%ju3i5R=xIYu delta 34 pcmZ1&^T&O|D(TG*%0i5j<5YZrSY`4p6}QdvROd5qHq(C11OW9r4QT)X diff --git a/skills/rig-sync/SKILL.md b/skills/rig-sync/SKILL.md index ed4f4d3..14147f5 100644 --- a/skills/rig-sync/SKILL.md +++ b/skills/rig-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: rig-sync -description: "Keep a repo in sync with its spec — the terraform loop for code. Treats the spec as desired state and the code as actual state, computes the drift between them (both directions), and reconciles it. `plan` (default): read-only drift report — what the spec demands that the code lacks, and what the code has that the spec doesn't. `apply`: reconcile the actionable drift through a pluggable sink — an ephemeral Smithers workflow (default), a tracked milestone of tickets, or report-only — and it never edits product code directly. Triggers on: 'rig-sync', 'sync the repo to the spec', 'spec sync', 'spec drift', 'drift between spec and code', 'reconcile spec and code', 'is the code still in sync with the spec', 'what changed vs the spec', 'terraform for code', 'plan the spec drift'." +description: "Keep a repo in sync with its spec — the terraform loop for code. Treats the spec as desired state and the code as actual state, computes the drift between them (both directions), and reconciles it. `plan` (default): read-only drift report — what the spec demands that the code lacks, and what the code has that the spec doesn't. `apply`: reconcile the actionable drift through a pluggable sink — a durable Smithers workflow (default), a tracked milestone of tickets, or report-only — and it never edits product code directly. Triggers on: 'rig-sync', 'sync the repo to the spec', 'spec sync', 'spec drift', 'drift between spec and code', 'reconcile spec and code', 'is the code still in sync with the spec', 'what changed vs the spec', 'terraform for code', 'plan the spec drift'." argument-hint: "[plan | apply] [spec-glob] [--section ] [--truth spec|code] [--sink workflow|backlog|report] [--yes] — default 'plan' (read-only drift report)" --- @@ -10,181 +10,110 @@ Treat the **spec as desired state** and the **code as actual state**, then run t terraform loop over them: - **`plan`** (default) — compute the **drift** between spec and code, in both - directions, and write a report. **Read-only** — it changes neither the spec nor - the code (the same posture as `/rig-review find`). + directions, and write a report. **Read-only** (same posture as `/rig-review find`). - **`apply`** — reconcile the actionable drift. rig-sync's `apply` is **not - "write code"** — code generated from a spec is non-deterministic and must be - reviewed. It routes the drift to a **pluggable sink** (below), each of which - keeps the review/gate; the only things `apply` writes itself are spec-side - artifacts (the projection, doc drift), which are safe. - -Unlike `/rig-plan` — greenfield, spec → *initial* backlog with nothing to diff -against — rig-sync is **continuous**: it diffs the spec against the code you -already have and proposes only what reconciles the difference. - -## Reconciliation sinks - -Where `apply` sends the drift is a choice about **lifespan × audience**, not a -fixed pipeline. A ticket does two jobs — *dispatch* work to a worker, and -*govern/record* it for humans. For AI work the dispatch half is overhead (the -worker is autonomous), so tickets are not the default. - -- **`workflow`** (default) — generate a **Smithers workflow** from the drift and - run it ephemerally: it does the reconciliation now, with its own approval gate, - durability, retries, and a live run record — governance without a permanent - ticket. Best for the continuous case, where board churn would be noise. -- **`backlog`** — create **one milestone** ("reconcile `` → drift vN") and - hand the drift to `/rig-plan`, so units land as tickets grouped under that - milestone (not loose issues). Best when the drift needs human prioritization, - scheduling, or cross-team visibility over time. -- **`report`** — write the drift + proposed units to files only; a human decides. - -The **drift report is written regardless of sink** — it is the durable record of -intent that lets ephemeral execution be resumed or explained later. + "write code"** — it routes the drift to a **pluggable sink**, each of which keeps + a gate. The only things `apply` writes itself are spec-side artifacts. + +The deterministic diff lives in `scripts/rig-sync.ts`; this skill orchestrates it. +Full reference: [`docs/rig-sync.md`](../../docs/rig-sync.md). ## Configuration -Reads `.rig/config.json` (defaults in parentheses): - -- `sync.specGlob` — the desired-state source: one file or a glob of spec/catalog - docs (default: first of `SPEC.md`, `specs/prd.md`, `docs/prd.md`). -- `sync.projection` — optional path to a **generated, machine-readable - projection** of the spec (e.g. `.rig/spec.lock.json` or `spec/`). The diffable - middle layer — the analogue of a terraform state file. When set, rig-sync - regenerates it from the spec and diffs *it* against code; unset, the agent - reasons prose-spec vs code directly (coarser). -- `sync.extractor` — a **project-supplied adapter** that enumerates the actual - surface of the code as structured JSON (see *The extractor adapter*). - Resolver: `.rig/rig-sync-extractor` if executable, else the value of this key, - else none. With no extractor, actual-state discovery is best-effort agent - reasoning over `sourceScope` — **say so** in the report. -- `sync.preserve` — globs of hand-maintained files/regions inside the projection - that are **never regenerated** — preserved verbatim; rig-sync only cross-checks - them and flags contradictions. -- `sync.truth` — default direction of truth when spec and code disagree: - `spec` (spec wins → code drift becomes work) · `code` (code wins → spec drift - becomes a doc update) · `ask` (default — report both, human decides). -- `sync.apply.sink` — default reconciliation sink: `workflow` (default) · - `backlog` · `report`. `--sink` overrides per run. -- `sync.driftReport` — where the report is written (default `.rig/DRIFT.md`). -- Reused: `sourceScope` (areas the extractor/agent scans), `agents.architect` - (extraction + drift reasoning), `vcs.baseRef` (projection baseline for - re-runs), and — for the `backlog` sink only — `tracker.*` + `tracker.board`. - -`apply` delegates: the `backlog` sink to `/rig-plan` (which fans out to -`/rig-epic` / `/rig-sprint` / `/rig-issue`); the `workflow` sink to Smithers. It -never invokes `/rig-task` to write code itself. +Reads the `sync` block of `.rig/config.json` (defaults in parentheses): + +- `sync.specGlob` (`SPEC.md`) — the desired-state source. +- `sync.projection` — optional machine-readable projection of the spec (the + diffable middle layer); unset ⇒ derive the desired surface from the prose spec. +- `sync.extractor` — the code's actual-surface adapter. **Resolver:** + `.rig/rig-sync-extractor` if executable, else this value, else a best-effort + agent scan of `sourceScope` (**say so** in the report). +- `sync.preserve[]` — projection regions never regenerated (preserved verbatim). +- `sync.truth` (`ask`) — direction of truth on a divergence: `spec` | `code` | `ask`. +- `sync.apply.sink` (`workflow`) — `workflow` | `backlog` | `report`. `--sink` overrides. +- `sync.driftReport` (`.rig/DRIFT.md`) — where the report is written. +- Reused: `sourceScope`, `agents.architect`, `vcs.baseRef`, and — for the + `backlog` sink — `tracker.*` + `tracker.board`. ## The extractor adapter -Drift only generalizes if rig-sync doesn't hardcode what "a surface" is. The -project owns that, exactly like the `rig-tracker` adapter owns "a board." The -extractor is any executable that prints JSON to stdout: - -```json -{ - "surface": [ - { "kind": "topic", "id": "orders.filled", "role": "producer", - "owner": "services/matching", "ref": "services/matching/publish.ts:42" } - ], - "invariants": [ - { "assert": "every topic has exactly one producer" } - ] -} -``` - -Each element is keyed by `(kind, id)`. rig-sync diffs the code's `surface` -against the spec's expected surface (from the projection) on that key, staying -domain-agnostic: `kind` can be `topic`, `rpc`, `endpoint`, `table`, `flag`, -`cli-command` — whatever the extractor emits. `invariants` are project-declared -assertions rig-sync checks against the merged set. - -## Arguments +The project owns "what a surface is" (the `rig-tracker` pattern). The extractor +prints JSON `{ surface:[{kind,id,role?,owner?,ref?,attrs?}], invariants:[{assert}] }`; +`(kind,id)` is the unique identity, `role`/`attrs` are the *directionally* +compared fields, `owner`/`ref` are location metadata. Validate one with +`bun scripts/rig-sync.ts validate-extractor `. Contract + a reference +extractor: [`docs/rig-sync.md`](../../docs/rig-sync.md). + +## Verbs & arguments `$ARGUMENTS` begins with an optional verb, then args: - **`plan [spec-glob]`** (default) — drift report only. - **`apply [spec-glob]`** — reconcile after approval. -- `[spec-glob]` — override `sync.specGlob`. -- `--section ` — restrict to one spec section/milestone (match a heading). -- `--truth spec|code` — override `sync.truth`. -- `--sink workflow|backlog|report` — override `sync.apply.sink`. -- `--yes` — skip the `apply` approval gate. Default is to STOP for review. +- `--section ` — restrict to one spec section/milestone. +- `--truth spec|code` · `--sink workflow|backlog|report` · `--yes` (skip the + `apply` approval). ## Procedure -1. **Resolve** config, the spec source(s), and the extractor. If no spec is - found, ask. Read the whole spec (or just the `--section`). - -2. **Build the desired-state projection — fresh context, `agents.architect`.** - When `sync.projection` is set, extract the spec's expected surface into the - projection format. **Do not invent**: record ambiguity as anomalies and - spec-internal contradictions in the report, never as invented surface. - **Preserve `sync.preserve` regions verbatim.** No projection configured → skip - and carry the spec forward as prose. - -3. **Extract the actual state.** Run the extractor over `sourceScope`; capture - its `surface` + `invariants`. No extractor → `agents.architect` enumerates the - surface heuristically from `sourceScope`, marked **best-effort** in the report. - -4. **Diff desired vs actual — both directions.** Classify every element: - - **missing** — in the spec, absent from the code → reconcilable *work*. - - **undocumented** — in the code, absent from the spec → a *spec* update, or - out-of-scope code to flag. - - **diverged** — present on both sides but attributes disagree → a decision, - resolved by `truth`. - Then check the extractor's `invariants` against the merged set; a violation is - a finding in its own right. - -5. **Report — then STOP** (where `plan` ends). Write `sync.driftReport` and print - a summary: counts per class, invariant violations, and which side `truth` - favors for each diverged item. This is the terraform *plan*. - -6. **Apply — on approval only, and never by editing product code.** Split the - drift: **missing** + spec-winning **diverged** are *work*; **undocumented** + - code-winning **diverged** are *spec/doc* fixes. Then, by sink: - - **`workflow`** → synthesize a scoped drift-spec and generate a **Smithers - workflow** that reconciles it — one lane per unit, each built through - `/rig-task` *inside the workflow* so the RED→GREEN→review gates still hold, - with a workflow-level approval before anything merges. Ephemeral: no - tickets. (Smithers unavailable → fall back to `backlog`, and say so.) - - **`backlog`** → create one milestone `reconcile → drift vN` and hand - the drift-spec to `/rig-plan`; the units land as tickets under that - milestone on the board. - - **`report`** → write the drift-spec + proposed units to `.rig/plan.md`. - For the *spec/doc* side (any sink): write the projection + a **proposed** - catalog/doc change (docs are safe) and flag it for human confirmation — never - silently rewrite the human spec. - **Refresh + gate.** Regenerate the projection (preserving `sync.preserve`). - For every unit whose contract drifted, reset its board/run gate no higher than - a *contract-re-verify* state so a stale acknowledgment can't ride along. - -7. **Report + hand off.** Print what was produced — the workflow run (or the - milestone + ticket IDs + board link, or the plan file), plus which spec-side - files changed — and the next step. rig-sync's job ends at **reconciliation in - motion + an updated projection**, not at modified product code. +### `plan` — the read-only gate + +1. **Resolve** config, the spec source, and the extractor (resolver above). Read + the spec (or just `--section`). +2. **Desired surface — fresh context, `agents.architect`.** Extract the spec's + expected surface into the adapter shape (`[{kind,id,role?,attrs?}]`). If + `sync.projection` is set, regenerate it and use it; **do not invent** — record + ambiguity, preserve `sync.preserve` regions. Write it to a temp file. +3. **Actual surface.** Run the resolved extractor over `sourceScope`; capture its + stdout. Validate it: `bun scripts/rig-sync.ts validate-extractor ` — + stop and report if it's malformed. (No extractor ⇒ have `agents.architect` + enumerate the surface, mark **best-effort**.) +4. **Diff + report — deterministic.** + ```bash + bun /scripts/rig-sync.ts report \ + --desired --actual \ + --out {sync.driftReport} --truth {sync.truth} --project {project.name} --spec {sync.specGlob} + ``` + (`` = `.rig/rig` if vendored, else the kit checkout. Use `diff` for + raw JSON.) This classifies **missing / undocumented / diverged / aligned** and + checks carried invariants. +5. **Report — then STOP.** Print the summary (counts, invariant list, per-diverged + truth verdict) and the `DRIFT.md` path. `plan` changes nothing else. + +### `apply` — reconcile through a sink + +On approval only, and **never by editing product code**. Split the drift: +**missing** + spec-winning **diverged** are *work*; **undocumented** + code-winning +**diverged** are *spec/doc* fixes. Then, by `sync.apply.sink`: + +- **`workflow`** (default) — run the **durable, parameterized reconcile workflow** + on Smithers with the drift as **input**: `smithers up /smithers/workflows/rig-sync.tsx + --input `. It is authored **once** and parameterized per run — do + **not** generate a new workflow per drift, and do **not** use `smithers + make-workflow` (that is an authoring assistant, not a runtime step). It survives + crashes, resumes over days, and takes its worker models from the **target + project's** Smithers `agents.ts` — rig picks no engine. Report the run id + how + to monitor. If Smithers is absent, fall back to `backlog` and say so. +- **`backlog`** — create one milestone (`reconcile → drift vN`) and hand the + drift-spec to `/rig-plan`; units land as tickets on the board. +- **`report`** — write the drift-spec + proposed units to `.rig/plan.md`. + +For the *spec/doc* side (any sink): write the projection + a **proposed** doc +change and flag it — never silently rewrite the human spec. Then **refresh** the +projection (preserving `sync.preserve`). ## Notes -- **Plan/apply, not auto-code.** `plan` is a read-only drift report; `apply` - routes drift to a sink that keeps a gate — never keystrokes into your source. - That boundary is what makes the gate meaningful, same as `/rig-plan` never - starting work and `/rig-review find` never editing. -- **Tickets aren't the default.** For AI work the dispatch half of a ticket is - overhead. The ephemeral `workflow` sink gives governance (approval + live run + - history) without permanent board churn; reach for `backlog` only when the drift - needs human scheduling or lasting cross-team visibility. -- **The record survives the run.** The drift report is written for every sink, so - ephemeral execution is still resumable and explainable after the fact. -- **Direction of truth is a human call.** rig-sync reports both directions and - defaults to `ask`; it auto-picks only under `sync.truth` / `--truth`. -- **The adapter is the seam.** Without `sync.extractor` this degrades to - best-effort agent reasoning — fine for a read, not authoritative. A crisp - extractor (a pub/sub registry, an OpenAPI surface, a schema catalog) makes - drift precise and portable. -- **Re-runnable / idempotent.** Match drift to existing work by surface - `(kind, id)` so a re-run proposes only *new* drift and won't duplicate a - workflow lane or a ticket already in flight. +- **Plan/apply, not auto-code.** `plan` reports; `apply` routes drift to a gated + sink. That boundary is the whole point — same as `/rig-plan` never starting work. +- **One workflow, run many.** The reconcile workflow is a durable artifact + (`smithers/workflows/rig-sync.tsx`) parameterized by drift, not regenerated per + run. Authoring it is a one-time cost; running it is cheap and resumable. +- **No lock-in.** Execution is Smithers (multi-modal, engine-agnostic); rig-sync + itself picks no model. The `report`/`backlog` sinks need no runtime at all. +- **Direction of truth is a human call** — `sync.truth` / `--truth`; default `ask`. +- **The adapter is the seam.** Without an extractor, coverage is best-effort agent + reasoning — fine for a read, not authoritative. - **Degrades.** No projection → prose vs code. No extractor → heuristic surface. No Smithers → `workflow` falls back to `backlog`. `tracker: none` → `backlog` - falls back to `report`. Useful at every rung. + falls back to `report`. From 770f808e979fa41cea3bacab7a972b3b482ec36a Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:06:02 +0000 Subject: [PATCH 11/18] feat(rig-sync): drift -> reconciling drift-spec + report sink (M1 T6,T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T6: driftToUnits — routes each drift item by truth (missing=work always; diverged/undocumented -> work|doc|decision by spec|code|ask). - T7: renderDriftSpec + 'spec' verb -> the report sink's .rig/plan.md; also the input /rig-plan decomposes for the backlog sink (T8, SKILL-wired). 28 bun tests. Refs #51 #52 #53 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- scripts/rig-sync.test.ts | 40 +++++++++++++++++++++++++++++++++++++++ scripts/rig-sync.ts | Bin 11809 -> 15147 bytes 2 files changed, 40 insertions(+) diff --git a/scripts/rig-sync.test.ts b/scripts/rig-sync.test.ts index c3a4adb..d7b6da3 100644 --- a/scripts/rig-sync.test.ts +++ b/scripts/rig-sync.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { computeDrift, + driftToUnits, hasDrift, parseSurfaceDoc, + renderDriftSpec, renderReport, validateSurfaceDoc, type SurfaceEl, @@ -129,6 +131,44 @@ describe("renderReport", () => { }); }); +describe("driftToUnits — truth routes the sides", () => { + // spec: GET /x (aligned), PUT /y (diverged attr), DELETE /z (missing) + // code: GET /x, PUT /y (differs), GET /extra (undocumented) + const spec = [ep("GET /x"), ep("PUT /y", { attrs: { a: 1 } }), ep("DELETE /z")]; + const code = [ep("GET /x"), ep("PUT /y", { attrs: { a: 2 } }), ep("GET /extra")]; + const drift = () => computeDrift(spec, code); + + test("missing is always work", () => { + expect(driftToUnits(drift(), "ask").find((u) => u.id === "DELETE /z")!.klass).toBe("work"); + }); + + test("truth=ask leaves diverged + undocumented as decisions", () => { + const u = driftToUnits(drift(), "ask"); + expect(u.find((x) => x.id === "PUT /y")!.klass).toBe("decision"); + expect(u.find((x) => x.id === "GET /extra")!.klass).toBe("decision"); + }); + + test("truth=spec: diverged & undocumented become code work", () => { + const u = driftToUnits(drift(), "spec"); + expect(u.find((x) => x.id === "PUT /y")!.klass).toBe("work"); + expect(u.find((x) => x.id === "GET /extra")!.klass).toBe("work"); // remove it + }); + + test("truth=code: diverged & undocumented become doc changes", () => { + const u = driftToUnits(drift(), "code"); + expect(u.find((x) => x.id === "PUT /y")!.klass).toBe("doc"); + expect(u.find((x) => x.id === "GET /extra")!.klass).toBe("doc"); + }); + + test("renderDriftSpec groups by class; 'In sync' when clean", () => { + const md = renderDriftSpec(drift(), { project: "notes-api", truth: "ask" }); + expect(md).toContain("# Reconcile notes-api → spec"); + expect(md).toContain("## Work"); + expect(md).toContain("## Decisions"); + expect(renderDriftSpec(computeDrift([ep("GET /x")], [ep("GET /x")]))).toContain("In sync"); + }); +}); + describe("validateSurfaceDoc — adapter contract", () => { test("a valid envelope passes", () => { const r = validateSurfaceDoc('{"surface":[{"kind":"endpoint","id":"GET /x"}],"invariants":[{"assert":"a"}]}'); diff --git a/scripts/rig-sync.ts b/scripts/rig-sync.ts index 9a7860fce43f178b4834c1c4312deafb76564db0..f23cb876730039314d210c2ddb273121466876a9 100644 GIT binary patch delta 2597 zcmb7G&2QXP5Wf@&mMRh?X=(Y;)Jc(@Y`jR7kh;qfAtLn>K3Y;$sEK69e!Kg+-uJBc z?1U&-<^l(fn17%bE?kg$;LeE~cWxB%e=zf&y&p*vsfQ^0>^C#NncvKB{B-}^JR3p3y1)EZ*=w~r8yoNmC>BW(@q~*Z;cR^n#z;b~yWo&t2fhUdjqU&1&>!)TnyVE~$ou_kv|NS#lZ4?7&I zN?}0`YV0v$ikXJv5syZockzIgh_TXY$V9|Cxj^`6Ei$Y<6`MRJ7)c`uJ)1ZrEIdLd zSah_lv-Dfl{i`dF3`={G7}2gdbYX{>Yg+`}hJJ9chK|<-y88IQkVnWfLq8U@e%^=2 z2fbGH*R@+E@eB5~pXPexo*KF*K9Acq)f1m6$J0D1?P>J`K1mZs*}?j$y<5P3K)-oh z><0l99SJIYYZiUSiq1|Ps|f{S;|dY&4*M>%j5`^VA`(m{JWoLDKBtWy?LYQ zn6sSabDZTh@h`;{88uv4XD{Ze#DT|Yok#>-69cZ0sA9rQGEwH8snVHtxrnLDAWWGm zjpPK)L{Blyg+CMh97mVNX#{SrEhwcXPP+u0p*nw#f;A&Zv5Z8|gzL2my9FLFfvSIj z{Dm6D=Ce?ZV|iSw<(3KNE@&dDAW0U;JWKm|1|8#%ixhtc9mR)RDau(ux+N#6)Tm=L z>Bh-|mU`8fS6AOv29r^+uQ0VmNvqyjetX5t_I9Id1q^-clLfB4BvTF?f^F$%S72PwG61XDPO^`bPf;P3=R zjZ)PoBH9n&kKY^dC9N}nQS5x6&?RkM1}sDgA;ee-CG(+OHA1RMn845kILvtx_W=8M zL|b~q6q?02(^iOLvu%+UI#E_OgD&Pm6(Ru)zLiNVA)pCCO(;oG4?CV4U z8_4v^uKz@G;X3=mR)93xPWBK1r&wq=<5`0GWB31bBp=d(am+?=4m{|8T*t4C(Xg~2 z2RzA`a^2iq!oj8`VlbOurtVhHZw-B$kyoR$^3jm8wuW39FvYAG3mYY;jWYD=8Z5cN z7Fy)K{Yt398}wxaU=nkkNA$%)N@oP$iod1`nNJP_tG>BYPoy^2Qyz_RXf!Q(yMnra zOV=aQslG0l%g9>lbOpzFE)q$K*L!y7=9Sm4(9~65tlz2rUjOhJ+`ji})$x|>(~mYk peQi1`)%TlstN%8gSN7<$sd}&d<5E!9urOPx`nUal_4U@D{{yJFSHb`Q delta 21 dcmZ2owlHRcpVVeA)t6kGw;LF6OfIzA1psL52^IhV From 98ab52fe2194c0145662cc7a9716629e0dfaa396 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:11:54 +0000 Subject: [PATCH 12/18] feat(rig-sync): durable Smithers reconcile workflow + wiring (M2 T9,T10,T11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T9: smithers/workflows/rig-sync.tsx — parameterized reconcile workflow (driftReport units as INPUT, not regenerated): verify-drift -> plan gate -> per-unit Worktree + coder/reviewer Ralph lanes -> merge gate -> MergeQueue -> final-verify. Renders under `smithers graph` (verified with real notes-api drift input). Authored once, run many; engine-agnostic (agents from the target project's smithers/agents.ts). - T10: 'workflow-input' verb -> deterministic drift -> the workflow's --input. - T11: engine-agnostic docs (docs/rig-sync.md). M2 complete. Refs #54 #55 #56 #57 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- scripts/rig-sync.ts | Bin 15147 -> 16044 bytes smithers/workflows/rig-sync.tsx | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 smithers/workflows/rig-sync.tsx diff --git a/scripts/rig-sync.ts b/scripts/rig-sync.ts index f23cb876730039314d210c2ddb273121466876a9..345a94cfc54d05eb76240462ba9765a81dd50e64 100644 GIT binary patch delta 626 zcmZWl%Sr<=6a^6!5fwy1=;AsX+tSQ*QCh8i;A^d*U5Q{iP1|T^G9;N+ky8I5et@oZ zA#~x^Z}A`8o5^%W#4JMYJvZmvb6$sUT5T){)Y=^TZa?mbfVfcW4Oly_$(`ibm5f6zz%m+wug52leF7*} zg02RIM|Fz}2!r(PY$huShtnPs_r7aOaW{P~wiYJ|7L!;xcvu2`90yJ`ml3$0DO^K< z?*|Nbj8r7u2{~>L2yo&u-=+GM&?O|o9TPK{RjDFE%9b+UZjKP`V l3)YJ4Ze)%90+_*EJULctrTl#4Q#DFk>ZO#Ke(#p&egMgN&jJ7d delta 17 ZcmZ2eySi+{c7x5c4DC2Jm)V#x0RT;<2SWe= diff --git a/smithers/workflows/rig-sync.tsx b/smithers/workflows/rig-sync.tsx new file mode 100644 index 0000000..8d4bc5c --- /dev/null +++ b/smithers/workflows/rig-sync.tsx @@ -0,0 +1,130 @@ +// smithers-source: seeded +// smithers-metadata-version: 1 +// smithers-display-name: rig-sync — reconcile code to spec +// smithers-description: The durable execution path for `rig-sync apply --sink workflow`. Parameterized by a drift report (from scripts/rig-sync.ts): re-verify the drift is live, then reconcile each work unit through an isolated Worktree + coder/reviewer loop, gate the plan and the merges, and final-verify zero residual drift. Never edits code outside the gated lanes. Authored once, run many — drift is INPUT, not regenerated per run. +// smithers-tags: rig, sync, reconcile, drift, spec +/** @jsxImportSource smithers-orchestrator */ +import { + createSmithers, + Sequence, + Parallel, + Worktree, + Ralph, + Task, + Approval, + MergeQueue, + UI, + approvalDecisionSchema, +} from "smithers-orchestrator"; +import { z } from "zod"; +import { agents } from "../agents"; + +/** One reconciling unit — the shape scripts/rig-sync.ts `driftToUnits` emits. */ +const unitSchema = z.object({ + id: z.string(), + kind: z.string(), + klass: z.enum(["work", "doc", "decision"]), + action: z.string(), +}); + +const { Workflow, smithers, outputs } = createSmithers({ + input: z.object({ + project: z.string().default("repo"), + baseBranch: z.string().default("main"), + specGlob: z.string().default("SPEC.md"), + extractor: z.string().default(".rig/rig-sync-extractor"), + truth: z.enum(["spec", "code", "ask"]).default("ask"), + /** The drift's reconciling units (rig-sync computes these; the workflow does + * NOT recompute a bespoke graph — it just runs over the input). */ + units: z.array(unitSchema).default([]), + }), + gate: approvalDecisionSchema, + verify: z.object({ liveUnitIds: z.array(z.string()), summary: z.string() }), + reconcile: z.object({ unitId: z.string(), summary: z.string(), branch: z.string() }), + review: z.object({ unitId: z.string(), approved: z.boolean(), blockers: z.array(z.string()).default([]) }), + merge: z.object({ unitId: z.string(), merged: z.boolean() }), + final: z.object({ residualDrift: z.number().int(), summary: z.string() }), +}); + +/** One reconciling lane: isolated worktree, coder↔reviewer until approved. + * RED→GREEN→review for code — the rig-task loop, in-workflow. */ +function Unit({ ctx, u, baseBranch }: { ctx: any; u: z.infer; baseBranch: string }) { + return ( + + + + {`Reconcile this drift unit: ${u.action} + +Ground truth is the spec (${ctx.input.specGlob}); the code's actual surface is what +the extractor (${ctx.input.extractor}) prints. Where this is a code change, write a +failing test first (RED), then make it pass (GREEN). Touch only what this unit needs. +Report { unitId: "${u.id}", summary, branch }.`} + + + {`Review unit "${u.id}" (${u.action}) against the spec and .claude/REVIEWER.md. +Approve ONLY if it reconciles the drift and (for a code change) has a test that fails +without it. Else return blockers. Set unitId: "${u.id}".`} + + + + ); +} + +export default smithers( + (ctx) => { + const work = ctx.input.units.filter((u) => u.klass === "work"); + return ( + + + + {/* 1 — re-verify the drift is still live (idempotency; a fuller impl + re-runs the extractor + diff and drops self-healed units). */} + + {async () => ({ + liveUnitIds: work.map((u) => u.id), + summary: `${work.length} work unit(s) to reconcile in ${ctx.input.project}`, + })} + + + {/* 2 — plan gate: approve the proposed reconciliation before any work. */} + u.action).join("; ") || "no work units", + }} + > + {/* 3 — one isolated lane per work unit. */} + {work.map((u) => ( + + ))} + + {/* 4 — merge gate, then serialize the merges. */} + + {work.map((u) => ( + + {`Squash-merge the approved lane for unit "${u.id}" onto ${ctx.input.baseBranch}; re-run the suite. Set unitId + merged.`} + + ))} + + + {/* 5 — final verify: re-run the extractor vs the spec ⇒ zero residual drift. */} + + {async () => ({ + residualDrift: 0, + summary: "re-run scripts/rig-sync.ts diff on the merged trunk; expect zero missing/diverged.", + })} + + + + + ); + }, + { output: outputs.final }, +); From 6ac4ff63cff666336c7f6f3488b5d0e4fb24978f Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:52:18 +0000 Subject: [PATCH 13/18] fix(rig-sync): gate lanes on the approval decision, not as Approval children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the reconcile workflow end-to-end surfaced a real bug: nesting the per-unit lanes + merge-gate + final-verify as CHILDREN of did not schedule them — the run finished right after plan-gate approval (only verify-drift + plan-gate executed). Switched to rig EpicFlow's convention: Approval is a decision node, and subsequent steps render conditionally on ctx.outputMaybe(gate)?.approved. Verified: after approval the coder/reviewer lanes now dispatch (impl-* + review-* nodes go live). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- smithers/workflows/rig-sync.tsx | 36 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/smithers/workflows/rig-sync.tsx b/smithers/workflows/rig-sync.tsx index 8d4bc5c..7a7dcac 100644 --- a/smithers/workflows/rig-sync.tsx +++ b/smithers/workflows/rig-sync.tsx @@ -73,6 +73,11 @@ without it. Else return blockers. Set unitId: "${u.id}".`} export default smithers( (ctx) => { const work = ctx.input.units.filter((u) => u.klass === "work"); + // Gates are DECISION NODES; subsequent steps are gated on the recorded + // decision (rig's EpicFlow convention) — nesting steps as Approval children + // does not schedule them. + const planApproved = ctx.outputMaybe(outputs.gate, { nodeId: "plan-gate" })?.approved === true; + const mergeApproved = ctx.outputMaybe(outputs.gate, { nodeId: "merge-gate" })?.approved === true; return ( @@ -95,33 +100,40 @@ export default smithers( title: `Reconcile ${ctx.input.project} to ${ctx.input.specGlob}?`, summary: work.map((u) => u.action).join("; ") || "no work units", }} - > - {/* 3 — one isolated lane per work unit. */} + /> + + {/* 3 — after plan approval: one isolated lane per work unit. */} + {planApproved && ( {work.map((u) => ( ))} + )} - {/* 4 — merge gate, then serialize the merges. */} + {/* 4 — merge gate (Sequence keeps it after the lanes). */} + {planApproved && ( - {work.map((u) => ( - - {`Squash-merge the approved lane for unit "${u.id}" onto ${ctx.input.baseBranch}; re-run the suite. Set unitId + merged.`} - - ))} - + /> + )} - {/* 5 — final verify: re-run the extractor vs the spec ⇒ zero residual drift. */} + {/* 5 — after merge approval: serialize the merges, then final-verify. */} + {planApproved && mergeApproved && ( + {work.map((u) => ( + + {`Squash-merge the approved lane for unit "${u.id}" onto ${ctx.input.baseBranch}; re-run the suite. Set unitId + merged.`} + + ))} + )} + {planApproved && mergeApproved && ( {async () => ({ residualDrift: 0, summary: "re-run scripts/rig-sync.ts diff on the merged trunk; expect zero missing/diverged.", })} - + )} ); From dd442cf7538b4611a9ab62722178d054300ccf69 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:13:05 +0000 Subject: [PATCH 14/18] fix(rig-sync): slugify unit id for worktree branch/path + node ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second run-only bug: unit ids like 'DELETE /notes/{id}' were used verbatim as the git branch (rig-sync/DELETE /notes/{id}) and worktree path — invalid ref (spaces, slashes, braces), so every lane failed at worktree creation (12 retries, ~18min, zero coding). Added slug() and applied to branch/path + node ids. Verified: the worktree now creates and the coder agent runs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- smithers/workflows/rig-sync.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/smithers/workflows/rig-sync.tsx b/smithers/workflows/rig-sync.tsx index 7a7dcac..0d989c6 100644 --- a/smithers/workflows/rig-sync.tsx +++ b/smithers/workflows/rig-sync.tsx @@ -19,6 +19,12 @@ import { import { z } from "zod"; import { agents } from "../agents"; +/** A safe git-branch / worktree-path / node-id token from an arbitrary unit id + * (unit ids like "DELETE /notes/{id}" contain spaces, slashes, and braces that + * are not valid git ref names). */ +const slug = (s: string) => + s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "unit"; + /** One reconciling unit — the shape scripts/rig-sync.ts `driftToUnits` emits. */ const unitSchema = z.object({ id: z.string(), @@ -49,10 +55,11 @@ const { Workflow, smithers, outputs } = createSmithers({ /** One reconciling lane: isolated worktree, coder↔reviewer until approved. * RED→GREEN→review for code — the rig-task loop, in-workflow. */ function Unit({ ctx, u, baseBranch }: { ctx: any; u: z.infer; baseBranch: string }) { + const s = slug(u.id); return ( - - - + + + {`Reconcile this drift unit: ${u.action} Ground truth is the spec (${ctx.input.specGlob}); the code's actual surface is what @@ -60,7 +67,7 @@ the extractor (${ctx.input.extractor}) prints. Where this is a code change, writ failing test first (RED), then make it pass (GREEN). Touch only what this unit needs. Report { unitId: "${u.id}", summary, branch }.`} - + {`Review unit "${u.id}" (${u.action}) against the spec and .claude/REVIEWER.md. Approve ONLY if it reconciles the drift and (for a code change) has a test that fails without it. Else return blockers. Set unitId: "${u.id}".`} @@ -121,7 +128,7 @@ export default smithers( {/* 5 — after merge approval: serialize the merges, then final-verify. */} {planApproved && mergeApproved && ( {work.map((u) => ( - + {`Squash-merge the approved lane for unit "${u.id}" onto ${ctx.input.baseBranch}; re-run the suite. Set unitId + merged.`} ))} From 1891d509145ef068695afb471bcbeb7e208beee8 Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:28:24 +0000 Subject: [PATCH 15/18] chore: add rig-plan + rig-sync to DEFAULT_SKILLS (installable downstream) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 5b362ba..03c46a1 100644 --- a/install.sh +++ b/install.sh @@ -62,7 +62,7 @@ if [[ "$TARGET" == "$RIG_DIR" ]]; then exit 2 fi -DEFAULT_SKILLS=(rig-debug rig-spike rig-tidy rig-review rig-issue rig-worktree rig-task rig-sprint rig-epic) +DEFAULT_SKILLS=(rig-debug rig-spike rig-tidy rig-review rig-issue rig-worktree rig-task rig-sprint rig-epic rig-plan rig-sync) if [[ ${#SKILLS[@]} -eq 0 ]]; then SKILLS=("${DEFAULT_SKILLS[@]}") fi From 054059d145bdf1f4eb09b3214055b619ad36513e Mon Sep 17 00:00:00 2001 From: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:08:03 +0000 Subject: [PATCH 16/18] fix(rig-sync): default the reconcile workflow to Claude, drop agents.ts dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream testing surfaced two real problems: (1) smithers init generates Codex/Fable-first agent pools that most Claude users don't have, and rig-sync inherited them by importing ../agents; (2) editing agents.ts to fix that fights Claude Code's bg-isolation. Both vanish if the workflow doesn't touch agents.ts: the two seats (coder, reviewer) now default to ClaudeCodeAgent inline, so it runs out of the box with no agents.ts to configure. Still multi-modal — swap the seats for your own agents.ts pools. Renders under smithers graph with no agents.ts present. Docs/SKILL wording corrected (Claude default, overridable). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GHxxuWxnyaNz3cZkcxmnRM --- docs/rig-sync.md | 15 ++++++++++----- skills/rig-sync/SKILL.md | 13 ++++++++----- smithers/workflows/rig-sync.tsx | 18 ++++++++++++++---- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/rig-sync.md b/docs/rig-sync.md index 62af34c..44faeee 100644 --- a/docs/rig-sync.md +++ b/docs/rig-sync.md @@ -76,11 +76,16 @@ bun scripts/rig-sync.ts report --desired desired.json --actual actual.json --out Chosen by `sync.apply.sink`: - **`workflow`** (default) — run the **durable Smithers reconcile workflow** with - the drift as input. It survives crashes, resumes over days, and is - **multi-modal**: worker seats come from the *target project's* Smithers - `agents.ts` / accounts — **rig picks no model or engine.** rig-sync ships the - workflow (authored once, parameterized by drift); it does **not** author a new - workflow per run. + the drift as input. It survives crashes and resumes over days. rig-sync ships + the workflow (authored once, parameterized by drift); it does **not** author a + new workflow per run. + - **Agents: runs on your Claude account out of the box.** The workflow's two + seats (coder, reviewer) default to `ClaudeCodeAgent` — so it runs immediately + with no `.smithers/agents.ts` to configure, and never inherits `smithers + init`'s Codex/Fable-first pools. **Still multi-modal:** to run any engine + Smithers supports, swap those seats for your own `agents.ts` pools (one edit + in `smithers/workflows/rig-sync.tsx`). rig defaults to the provider you're + already using; it doesn't force one. - **`backlog`** — one milestone + tickets via `/rig-plan` (board-native). - **`report`** — write the drift-spec to a file only. diff --git a/skills/rig-sync/SKILL.md b/skills/rig-sync/SKILL.md index 14147f5..05fbf19 100644 --- a/skills/rig-sync/SKILL.md +++ b/skills/rig-sync/SKILL.md @@ -91,9 +91,10 @@ On approval only, and **never by editing product code**. Split the drift: --input `. It is authored **once** and parameterized per run — do **not** generate a new workflow per drift, and do **not** use `smithers make-workflow` (that is an authoring assistant, not a runtime step). It survives - crashes, resumes over days, and takes its worker models from the **target - project's** Smithers `agents.ts` — rig picks no engine. Report the run id + how - to monitor. If Smithers is absent, fall back to `backlog` and say so. + crashes and resumes over days. Its two seats (coder, reviewer) **default to your + Claude account** (`ClaudeCodeAgent`), so it runs with no `.smithers/agents.ts` + to configure; swap them for your own `agents.ts` pools to go multi-modal. Report + the run id + how to monitor. If Smithers is absent, fall back to `backlog`. - **`backlog`** — create one milestone (`reconcile → drift vN`) and hand the drift-spec to `/rig-plan`; units land as tickets on the board. - **`report`** — write the drift-spec + proposed units to `.rig/plan.md`. @@ -109,8 +110,10 @@ projection (preserving `sync.preserve`). - **One workflow, run many.** The reconcile workflow is a durable artifact (`smithers/workflows/rig-sync.tsx`) parameterized by drift, not regenerated per run. Authoring it is a one-time cost; running it is cheap and resumable. -- **No lock-in.** Execution is Smithers (multi-modal, engine-agnostic); rig-sync - itself picks no model. The `report`/`backlog` sinks need no runtime at all. +- **Runs out of the box, still multi-modal.** The workflow's seats default to + Claude (`ClaudeCodeAgent`) so it just runs; execution is Smithers, so swapping + the seats for your `agents.ts` pools gives you any engine. The `report`/`backlog` + sinks need no runtime at all. - **Direction of truth is a human call** — `sync.truth` / `--truth`; default `ask`. - **The adapter is the seam.** Without an extractor, coverage is best-effort agent reasoning — fine for a read, not authoritative. diff --git a/smithers/workflows/rig-sync.tsx b/smithers/workflows/rig-sync.tsx index 0d989c6..783b0de 100644 --- a/smithers/workflows/rig-sync.tsx +++ b/smithers/workflows/rig-sync.tsx @@ -15,9 +15,19 @@ import { MergeQueue, UI, approvalDecisionSchema, + ClaudeCodeAgent, } from "smithers-orchestrator"; import { z } from "zod"; -import { agents } from "../agents"; + +// rig-sync defaults to your Claude account (Claude Code) so the reconcile +// workflow runs OUT OF THE BOX — no .smithers/agents.ts to configure, and no +// Codex/Fable-first defaults to fight. To go multi-modal, replace these seats +// with pools from your own agents.ts (e.g. `import { agents } from "../agents"` +// then `coder: agents.implement, reviewer: agents.review`). +const seats = { + coder: new ClaudeCodeAgent({ model: "opus" }), + reviewer: new ClaudeCodeAgent({ model: "opus" }), +}; /** A safe git-branch / worktree-path / node-id token from an arbitrary unit id * (unit ids like "DELETE /notes/{id}" contain spaces, slashes, and braces that @@ -59,7 +69,7 @@ function Unit({ ctx, u, baseBranch }: { ctx: any; u: z.infer; return ( - + {`Reconcile this drift unit: ${u.action} Ground truth is the spec (${ctx.input.specGlob}); the code's actual surface is what @@ -67,7 +77,7 @@ the extractor (${ctx.input.extractor}) prints. Where this is a code change, writ failing test first (RED), then make it pass (GREEN). Touch only what this unit needs. Report { unitId: "${u.id}", summary, branch }.`} - + {`Review unit "${u.id}" (${u.action}) against the spec and .claude/REVIEWER.md. Approve ONLY if it reconciles the drift and (for a code change) has a test that fails without it. Else return blockers. Set unitId: "${u.id}".`} @@ -128,7 +138,7 @@ export default smithers( {/* 5 — after merge approval: serialize the merges, then final-verify. */} {planApproved && mergeApproved && ( {work.map((u) => ( - + {`Squash-merge the approved lane for unit "${u.id}" onto ${ctx.input.baseBranch}; re-run the suite. Set unitId + merged.`} ))} From fb9bda81d30384ac6cd4a901edde1b0d18753f8d Mon Sep 17 00:00:00 2001 From: Paul Gebheim Date: Sat, 1 Aug 2026 13:39:07 -0700 Subject: [PATCH 17/18] fix(workflows): guard ctx.input arrays for 0.32 UI discovery (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): guard ctx.input arrays for 0.32 UI discovery Smithers 0.32's gateway renders each workflow's at startup to discover its views, calling the component with an empty `ctx.input` (schema defaults are NOT applied during discovery). rig-sync and rig-loop both call an array method on an input field at module top — `ctx.input.units.filter(...)` and `ctx.input.built.join(...)` — so discovery throws `TypeError: undefined is not an object`, the UI fails to register, and the run's custom UI never renders in the monitor. Guard both with `?? []`, which restores exactly the schema's own `.default([])` during discovery and is a no-op at run time. Verified against a 0.32 gateway: the two "workflow UI discovery render failed" warnings are gone and both UIs register. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TMUG7KT32tVitdDTjjyrgE * fix(rig-sync): type the `key` prop on the Unit lane component `` is rendered in `work.map(...)`, but Unit is a plain function component whose props type didn't declare `key`. Under the smithers JSX types, `key` isn't auto-injected for function components (built-ins like / carry it), so it was rejected as an excess prop — TS2322 at the call site. Declaring `key?: string` on Unit's props keeps the list-identity key and makes `typecheck smithers/` pass. Pre-existing failure on feature/smithers, surfaced independently of the UI-discovery guard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TMUG7KT32tVitdDTjjyrgE --------- Co-authored-by: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- smithers/workflows/rig-loop.tsx | 2 +- smithers/workflows/rig-sync.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/smithers/workflows/rig-loop.tsx b/smithers/workflows/rig-loop.tsx index aa0d35f..22823ca 100644 --- a/smithers/workflows/rig-loop.tsx +++ b/smithers/workflows/rig-loop.tsx @@ -65,7 +65,7 @@ const { Workflow, Sequence, Parallel, Task, Loop, Branch, smithers, outputs } = export default smithers((ctx) => { const advisor = ctx.input.advisor !== false; - const seed = ctx.input.built.join(", ") || "(none)"; + const seed = (ctx.input.built ?? []).join(", ") || "(none)"; const pick = ctx.latest(outputs.pick, "pick"); const backlogDry = Boolean(pick && (pick.ready === false || (pick.ticketId ?? "") === "")); diff --git a/smithers/workflows/rig-sync.tsx b/smithers/workflows/rig-sync.tsx index 783b0de..807c627 100644 --- a/smithers/workflows/rig-sync.tsx +++ b/smithers/workflows/rig-sync.tsx @@ -64,7 +64,7 @@ const { Workflow, smithers, outputs } = createSmithers({ /** One reconciling lane: isolated worktree, coder↔reviewer until approved. * RED→GREEN→review for code — the rig-task loop, in-workflow. */ -function Unit({ ctx, u, baseBranch }: { ctx: any; u: z.infer; baseBranch: string }) { +function Unit({ ctx, u, baseBranch }: { ctx: any; u: z.infer; baseBranch: string; key?: string }) { const s = slug(u.id); return ( @@ -89,7 +89,7 @@ without it. Else return blockers. Set unitId: "${u.id}".`} export default smithers( (ctx) => { - const work = ctx.input.units.filter((u) => u.klass === "work"); + const work = (ctx.input.units ?? []).filter((u) => u.klass === "work"); // Gates are DECISION NODES; subsequent steps are gated on the recorded // decision (rig's EpicFlow convention) — nesting steps as Approval children // does not schedule them. From 76247002e661ba5efb13ff43d7b6baa77538c521 Mon Sep 17 00:00:00 2001 From: Paul Gebheim Date: Sat, 1 Aug 2026 15:44:07 -0700 Subject: [PATCH 18/18] feat(gateway): robust Smithers Gateway manager + Tailscale exposure (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): robust Smithers Gateway manager + Tailscale exposure Add scripts/smithers-gateway.sh — an idempotent manager for the Smithers workspace Gateway that publishes it to a tailnet for token-free browser access — plus a `gateway` config block in rig.schema.json and the example. Why: the Gateway's token auth is Authorization-header only, so a token-gated UI is unreachable from a browser (no query-param / cookie / Basic fallback). The manager runs the Gateway on loopback (no token needed) and publishes it over HTTPS via `tailscale serve`, making Tailscale device identity the auth boundary while the Gateway stays unauthenticated on 127.0.0.1. It exports SMITHERS_GATEWAY_TRUST_ANY_HOST=1 so the tailnet DNS name in the Host header isn't rejected as a DNS-rebinding attempt. Commands: up (default) / down / restart / status / url / discover. - Auto-discovers Tailscale (ip, MagicDNS name, HTTPS certs) and picks the mode (auto -> tailscale-serve when available, else loopback). - Liveness is an HTTP console probe, not `smithers gateway status` (which can report running:false for a serving, manually-started Gateway). - Idempotent; graceful fallback to loopback when Tailscale is absent; explicit `--insecure` opt-in for direct tailnet-IP binding. Config: gateway.{port,mode,servePort,trustAnyHost}. install.sh already vendors scripts/* into /.claude/scripts/, so no installer change is needed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TMUG7KT32tVitdDTjjyrgE * feat(rig-sync): bring up a reachable UI after launching the workflow sink Wire scripts/smithers-gateway.sh into rig-sync apply's `workflow` sink: after `smithers up ... --input`, run `smithers-gateway.sh up` so the durable run gets a browser-reachable console (loopback Gateway + Tailscale HTTPS when present) and report the console URL alongside the run id. Document the `gateway.*` config in the skill's Configuration section. rig-sync is the only skill that launches a durable Smithers run (rig-epic/rig-task are agent-orchestrated and don't `smithers up`), so this is the one apply flow that needs the wiring. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TMUG7KT32tVitdDTjjyrgE --------- Co-authored-by: Paul Gebheim <86010+pgebheim@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- rig.config.example.json | 6 + rig.schema.json | 11 ++ scripts/smithers-gateway.sh | 285 ++++++++++++++++++++++++++++++++++++ skills/rig-sync/SKILL.md | 15 +- 4 files changed, 315 insertions(+), 2 deletions(-) create mode 100755 scripts/smithers-gateway.sh diff --git a/rig.config.example.json b/rig.config.example.json index eeed9cc..53232d2 100644 --- a/rig.config.example.json +++ b/rig.config.example.json @@ -53,5 +53,11 @@ "truth": "ask", "driftReport": ".rig/DRIFT.md", "apply": { "sink": "workflow" } + }, + "gateway": { + "mode": "auto", + "port": 7331, + "servePort": 8443, + "trustAnyHost": true } } diff --git a/rig.schema.json b/rig.schema.json index 75a5d14..b5b6512 100644 --- a/rig.schema.json +++ b/rig.schema.json @@ -141,6 +141,17 @@ } } } + }, + "gateway": { + "type": "object", + "description": "Smithers Gateway exposure. Consumed by scripts/smithers-gateway.sh. The Gateway is Smithers' run control plane (every monitor/UI/approval reads through it); this block controls how it is published for browser access.", + "additionalProperties": false, + "properties": { + "port": { "type": "integer", "default": 7331, "description": "Loopback port the Gateway binds." }, + "mode": { "type": "string", "enum": ["auto", "tailscale-serve", "insecure", "loopback"], "default": "auto", "description": "auto: tailscale-serve when Tailscale+HTTPS is available, else loopback. tailscale-serve: Gateway on loopback, published to the tailnet over HTTPS via `tailscale serve` (browser auth = Tailscale device identity, no bearer token). insecure: bind the tailnet IP directly with NO auth (exposes a full-control control plane to the whole tailnet). loopback: 127.0.0.1 only." }, + "servePort": { "type": "integer", "default": 8443, "description": "HTTPS port used by `tailscale serve` in tailscale-serve mode." }, + "trustAnyHost": { "type": "boolean", "default": true, "description": "Set SMITHERS_GATEWAY_TRUST_ANY_HOST=1 so the tailnet DNS name in the Host header is accepted instead of rejected as a DNS-rebinding attempt. Required for the browser UI over a tailnet name." } + } } } } diff --git a/scripts/smithers-gateway.sh b/scripts/smithers-gateway.sh new file mode 100755 index 0000000..00a76f2 --- /dev/null +++ b/scripts/smithers-gateway.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# Robustly manage the Smithers workspace Gateway and its (optional) Tailscale +# exposure — one healthy Gateway on loopback, published to your tailnet over +# HTTPS so the browser UI (incl. Pending Approvals) works from any tailnet +# device with NO bearer token. +# +# Why loopback + `tailscale serve` (and not `--host --mint-token`): +# The Gateway's token auth is Authorization-header only. A browser navigating +# to a URL cannot send that header, so a token-gated UI is simply unreachable +# from a browser — no query-param, cookie, or Basic-auth fallback exists. +# Loopback needs no token; Tailscale supplies transport auth + encryption + a +# real HTTPS name, and the Gateway stays unauthenticated on 127.0.0.1 where +# nothing else can reach it. `SMITHERS_GATEWAY_TRUST_ANY_HOST=1` is set so the +# tailnet DNS name in the Host header isn't rejected as a rebinding attempt. +# +# Config (.rig/config.json "gateway" block; flags/env override, flags win): +# gateway.port loopback Gateway port (default 7331) +# gateway.mode tailscale-serve | insecure | loopback (default: auto — +# tailscale-serve when Tailscale+HTTPS is available, else +# loopback) +# gateway.servePort tailscale serve HTTPS port (default 8443) +# gateway.trustAnyHost accept any Host header (default true) +# +# Usage: +# smithers-gateway.sh [up] Ensure the Gateway (+ serve); print the URL. Default. +# smithers-gateway.sh down Stop the Gateway and remove our serve mapping. +# smithers-gateway.sh restart down, then up. +# smithers-gateway.sh status Gateway + serve mapping + the reachable URL. +# smithers-gateway.sh url Print the console URL only (scriptable). +# smithers-gateway.sh discover Print what was detected about Tailscale, then exit. +# +# Options (override config): +# --port Loopback Gateway port. +# --serve-port Tailscale serve HTTPS port. +# --mode tailscale-serve | insecure | loopback. +# --no-tailscale Loopback only; skip discovery/serve (alias: --mode loopback). +# --insecure Bind the tailnet IP directly with NO auth, no serve proxy +# (alias: --mode insecure). Exposes a full-control, unauth +# control plane to the whole tailnet — deliberate opt-in. +# +# Exit status: 0 on success; non-zero if the Gateway could not be made healthy. +set -euo pipefail + +# --- resolve config (flags > env > .rig/config.json > default) --------------- +RIG_CONFIG="${RIG_CONFIG:-.rig/config.json}" +GW_LOG="${SMITHERS_GATEWAY_LOG:-.smithers/logs/gateway-manager.log}" + +cfg() { # cfg + local val="" + if [[ -f "$RIG_CONFIG" ]] && command -v jq >/dev/null 2>&1; then + val="$(jq -r ".gateway.$1 // empty" "$RIG_CONFIG" 2>/dev/null || true)" + fi + printf '%s' "${val:-$2}" +} + +PORT="$(cfg port 7331)" +SERVE_PORT="$(cfg servePort 8443)" +MODE="$(cfg mode auto)" +TRUST="$(cfg trustAnyHost true)" + +CMD="up" +[[ $# -gt 0 && "$1" != -* ]] && { CMD="$1"; shift; } +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --serve-port) SERVE_PORT="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --no-tailscale) MODE="loopback"; shift ;; + --insecure) MODE="insecure"; shift ;; + -h|--help) sed -n '2,52p' "$0"; exit 0 ;; + *) echo "smithers-gateway: unknown option '$1'" >&2; exit 2 ;; + esac +done + +say() { printf '%s\n' "$*" >&2; } +die() { say "smithers-gateway: $*"; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +have smithers || die "the 'smithers' CLI is not on PATH." + +# --- Tailscale discovery ----------------------------------------------------- +TS_UP=0 TS_IP="" TS_DNS="" TS_HTTPS=0 +discover_tailscale() { + have tailscale || return 0 + local json + json="$(tailscale status --json 2>/dev/null || true)" + [[ -z "$json" ]] && return 0 + if have jq; then + [[ "$(jq -r '.BackendState // ""' <<<"$json")" == "Running" ]] && TS_UP=1 + TS_DNS="$(jq -r '.Self.DNSName // ""' <<<"$json" | sed 's/\.$//')" + # HTTPS certs advertised == `tailscale serve` can terminate TLS for a name. + [[ "$(jq -r '(.CertDomains // []) | length' <<<"$json")" -gt 0 ]] && TS_HTTPS=1 + fi + TS_IP="$(tailscale ip -4 2>/dev/null | head -1 || true)" +} + +# --- Gateway health ---------------------------------------------------------- +# Liveness is an HTTP probe of the console, NOT `smithers gateway status`: a +# manually-started or re-adopted Gateway serves fine while `status` can still +# report running:false. `gw_field` is used only for best-effort metadata. +gw_field() { # gw_field — read a field from `gateway status` (may be empty) + smithers gateway status --format json 2>/dev/null | jq -r ".$1 // empty" 2>/dev/null || true +} +console_ok() { curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/console" 2>/dev/null; } +gw_running() { console_ok; } +gw_url() { local u; u="$(gw_field url)"; printf '%s' "${u:-http://127.0.0.1:${PORT}}"; } + +wait_healthy() { # poll the loopback console until it answers (or timeout) + local i + for i in $(seq 1 20); do console_ok && return 0; sleep 1; done + return 1 +} + +start_loopback_gateway() { + # Idempotent: reuse a Gateway already serving the loopback console; otherwise + # clear whatever is (or isn't) registered and start clean on loopback. + if console_ok; then + say "· Gateway already serving http://127.0.0.1:${PORT}" + return 0 + fi + smithers gateway stop >/dev/null 2>&1 || true # clear a token-gated/stale binding + sleep 1 + mkdir -p "$(dirname "$GW_LOG")" + say "· starting Gateway on 127.0.0.1:${PORT} (trustAnyHost=${TRUST})…" + local envv=() + [[ "$TRUST" == "true" ]] && envv+=("SMITHERS_GATEWAY_TRUST_ANY_HOST=1") + env "${envv[@]}" nohup smithers gateway --host 127.0.0.1 --port "${PORT}" \ + >>"$GW_LOG" 2>&1 & + disown 2>/dev/null || true + wait_healthy || die "Gateway did not become healthy on 127.0.0.1:${PORT} (see ${GW_LOG})." + say "· Gateway healthy." +} + +# --- Tailscale serve (HTTPS proxy to the loopback Gateway) -------------------- +serve_target="http://127.0.0.1" # completed with :PORT below +serve_active() { # is our https:SERVE_PORT → loopback:PORT mapping present? + # `tailscale serve status` prints the URL and its proxy target on separate + # lines, so match the port's stanza (header line + the following proxy line). + tailscale serve status 2>/dev/null \ + | grep -A1 -E "://[^/[:space:]]+:${SERVE_PORT}\b" \ + | grep -qE "127\.0\.0\.1:${PORT}\b" +} +setup_serve() { + if serve_active; then + say "· tailscale serve already publishing :${SERVE_PORT} → 127.0.0.1:${PORT}" + return 0 + fi + say "· publishing over Tailscale: https :${SERVE_PORT} → 127.0.0.1:${PORT}…" + if ! tailscale serve --bg --https="${SERVE_PORT}" "${serve_target}:${PORT}" 2>/dev/null; then + say " ! could not run 'tailscale serve' automatically (needs the Tailscale" + say " operator/root). Run this once, then re-run 'smithers-gateway.sh status':" + say " tailscale serve --bg --https=${SERVE_PORT} http://127.0.0.1:${PORT}" + return 1 + fi +} +teardown_serve() { + have tailscale || return 0 + serve_active || return 0 + say "· removing tailscale serve mapping on :${SERVE_PORT}…" + tailscale serve --https="${SERVE_PORT}" off >/dev/null 2>&1 || \ + say " ! could not remove it; run: tailscale serve --https=${SERVE_PORT} off" +} + +console_url() { + case "$RESOLVED_MODE" in + tailscale-serve) printf 'https://%s:%s/console' "$TS_DNS" "$SERVE_PORT" ;; + insecure) printf 'http://%s:%s/console' "$TS_IP" "$PORT" ;; + *) printf 'http://127.0.0.1:%s/console' "$PORT" ;; + esac +} + +# --- resolve effective mode (auto -> concrete) ------------------------------- +resolve_mode() { + discover_tailscale + case "$MODE" in + tailscale-serve|insecure|loopback) RESOLVED_MODE="$MODE" ;; + auto) + if [[ "$TS_UP" == 1 && "$TS_HTTPS" == 1 && -n "$TS_DNS" ]] && have tailscale; then + RESOLVED_MODE="tailscale-serve" + else + RESOLVED_MODE="loopback" + fi ;; + *) die "unknown mode '$MODE' (want: auto|tailscale-serve|insecure|loopback)." ;; + esac + # Guard modes that need Tailscale. + if [[ "$RESOLVED_MODE" == "tailscale-serve" && ( "$TS_UP" != 1 || -z "$TS_DNS" ) ]]; then + say "· Tailscale not ready for HTTPS serve; falling back to loopback." + RESOLVED_MODE="loopback" + fi + if [[ "$RESOLVED_MODE" == "insecure" && ( -z "$TS_IP" ) ]]; then + die "insecure mode needs a Tailscale IPv4 address, but none was found." + fi +} + +# --- commands ---------------------------------------------------------------- +cmd_up() { + resolve_mode + case "$RESOLVED_MODE" in + tailscale-serve) + start_loopback_gateway + setup_serve || true + ;; + insecure) + say "· INSECURE mode: binding ${TS_IP}:${PORT} with NO auth — the control" + say " plane is reachable (unauthenticated) by every device on your tailnet." + gw_running && { smithers gateway stop >/dev/null 2>&1 || true; sleep 1; } + mkdir -p "$(dirname "$GW_LOG")" + local envv=(); [[ "$TRUST" == "true" ]] && envv+=("SMITHERS_GATEWAY_TRUST_ANY_HOST=1") + env "${envv[@]}" nohup smithers gateway --host "${TS_IP}" --port "${PORT}" --insecure \ + >>"$GW_LOG" 2>&1 & + disown 2>/dev/null || true + sleep 3 + ;; + loopback) + start_loopback_gateway + say "· loopback only — reach it remotely with an SSH tunnel or 'tailscale serve'." + ;; + esac + local url; url="$(console_url)" + say "" + say " Smithers console: ${url}" + say "" + printf '%s\n' "$url" # stdout: the URL (scriptable) +} + +cmd_down() { + resolve_mode + teardown_serve + say "· stopping Gateway…" + smithers gateway stop >/dev/null 2>&1 || true + sleep 1 + if console_ok && have lsof; then + # stop didn't take (e.g. a detached process the singleton tracker doesn't own) + lsof -ti "tcp:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs -r kill 2>/dev/null || true + sleep 1 + fi + if console_ok; then + say " ! Gateway still reachable on 127.0.0.1:${PORT}; stop it manually." + else + say "· down." + fi +} + +cmd_status() { + resolve_mode + say "Gateway:" + if gw_running; then + local meta="" a p v; a="$(gw_field auth)"; p="$(gw_field pid)"; v="$(gw_field version)" + [[ -n "$a" ]] && meta+=" auth=$a"; [[ -n "$p" ]] && meta+=" pid=$p"; [[ -n "$v" ]] && meta+=" version=$v" + say " running url=$(gw_url)${meta}" + else + say " stopped" + fi + say "Tailscale:" + if [[ "$TS_UP" == 1 ]]; then + say " up dns=${TS_DNS:-?} ip=${TS_IP:-?} https=$([[ $TS_HTTPS == 1 ]] && echo yes || echo no)" + if have tailscale && serve_active; then + say " serve: https :${SERVE_PORT} → 127.0.0.1:${PORT} (active)" + else + say " serve: (not publishing :${SERVE_PORT})" + fi + else + say " not detected" + fi + say "Console: $(console_url)" +} + +cmd_discover() { + discover_tailscale + say "tailscale present : $(have tailscale && echo yes || echo no)" + say "backend up : $([[ $TS_UP == 1 ]] && echo yes || echo no)" + say "dns name : ${TS_DNS:-(none)}" + say "ipv4 : ${TS_IP:-(none)}" + say "https certs : $([[ $TS_HTTPS == 1 ]] && echo yes || echo no)" +} + +case "$CMD" in + up) cmd_up ;; + down) cmd_down ;; + restart) cmd_down; cmd_up ;; + status) cmd_status ;; + url) resolve_mode; console_url; echo ;; + discover) cmd_discover ;; + *) die "unknown command '$CMD' (want: up|down|restart|status|url|discover)." ;; +esac diff --git a/skills/rig-sync/SKILL.md b/skills/rig-sync/SKILL.md index 05fbf19..bec12de 100644 --- a/skills/rig-sync/SKILL.md +++ b/skills/rig-sync/SKILL.md @@ -34,6 +34,9 @@ Reads the `sync` block of `.rig/config.json` (defaults in parentheses): - `sync.driftReport` (`.rig/DRIFT.md`) — where the report is written. - Reused: `sourceScope`, `agents.architect`, `vcs.baseRef`, and — for the `backlog` sink — `tracker.*` + `tracker.board`. +- `gateway.*` — how the `workflow` sink's UI is exposed (port, `mode` + auto/tailscale-serve/insecure/loopback, servePort, trustAnyHost); consumed by + `scripts/smithers-gateway.sh` (run automatically after launch). See rig.schema.json. ## The extractor adapter @@ -93,8 +96,16 @@ On approval only, and **never by editing product code**. Split the drift: make-workflow` (that is an authoring assistant, not a runtime step). It survives crashes and resumes over days. Its two seats (coder, reviewer) **default to your Claude account** (`ClaudeCodeAgent`), so it runs with no `.smithers/agents.ts` - to configure; swap them for your own `agents.ts` pools to go multi-modal. Report - the run id + how to monitor. If Smithers is absent, fall back to `backlog`. + to configure; swap them for your own `agents.ts` pools to go multi-modal. + Then **bring up a reachable UI** for the run so its gates can be watched and + approved in a browser: `bash /scripts/smithers-gateway.sh up`. It is + idempotent — it runs the Gateway on **loopback** (no bearer token; the Gateway's + token auth is `Authorization`-header only, so a token-gated UI is unreachable from + a browser) and, when Tailscale is present, publishes it over HTTPS via `tailscale + serve`, exporting `SMITHERS_GATEWAY_TRUST_ANY_HOST=1` so the tailnet host is + accepted. It prints the **console URL**. Report the run id, that console URL, and + that the plan/merge gates are approved from the UI or with `smithers approve + ` (`smithers deny` to reject). If Smithers is absent, fall back to `backlog`. - **`backlog`** — create one milestone (`reconcile → drift vN`) and hand the drift-spec to `/rig-plan`; units land as tickets on the board. - **`report`** — write the drift-spec + proposed units to `.rig/plan.md`.