@piquette/engine
v0.48.0
Published
Piquette — a global CLI for repeatable agents-plus-code workflows (ADWs)
Readme
Piquette
Repeatable agents-plus-code workflows, as a global CLI you run against any repo. Deterministic TypeScript owns the graph. Coding agents are bounded nodes inside it.
A software factory does one thing: it gives you more leverage on your prompt. How much leverage depends entirely on what you invest in it. At the low end you chain two agents together and hope. At the high end you build a system of agents plus code that runs without you, and does the job about as well as you would.
Everyone can get an agent to write code once. Almost nobody gets the same result twice. This fixes that by moving the control plane out of the prompt and into TypeScript. A chain script owns sequencing, retries, and acceptance. Agents work inside named phases. Typed JSON envelopes carry context across the seams. Every event streams into SQLite while it is still happening. Agent proposes, code disposes.
Beta. PIQUETTE is under active development — config schema, chain/CLI surface, and trace/event shapes may change between releases without a deprecation period. Pin a version if you depend on any of these staying stable.
Install
npm i -g @piquette/engineNo Bun, no separate coding-agent binary to install first, no template stamped into your repo. piquette ships with a packaged default agent roster and default prompts — it runs against any repo with zero setup, and only writes files into that repo if you explicitly ask it to (piquette init, which also installs the Claude Code skill unless you pass --no-skills; piquette install-skill does just the skill by hand).
cd your-repo
piquette doctor # confirm everything resolves — paths, roster, provider keys
piquette scout "describe this repo" # a real, read-only run, no setup requiredpiquette doctor is the answer to "why did nothing happen": it resolves and prints every path, validates the whole roster and every quality suite, and checks the things that fail silently otherwise — a missing provider key, a quality check whose binary isn't on PATH, a stale protected_files pattern.
Setting this up with an AI agent? Paste
START.mdinto the agent's context (Cursor rules, AGENTS.md, Codex, a bare Claude conversation). It walks the agent through install, a short configuration interview, and first runs.
Customizing a repo
piquette init # on a TTY: an interview — agent, model, quality checks, receipts, piquette watch, secrets
piquette init --yes # skip the interview — seed the commented, all-defaults starter config instead
piquette init --template ts-cc # or start from a packaged, ready-to-run template instead
piquette init --partial # reconfigure one or more existing blocks without re-answering the rest
piquette list # every chain this install knows, its phases, what it needsOn a real terminal, piquette init asks a short interview — which coding agent (claude_code, flue, or opencode) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to capture verification receipts (and where — local, or an S3-compatible bucket such as R2), whether to turn on piquette watch and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes .piquette/piquette.config.yaml with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to .env (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable .env.example. Re-running it later shows any existing .env value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, --yes, or --template <name> all skip the interview and fall back to the original non-interactive behavior — a scripted piquette init never blocks on stdin.
piquette init --partial picks up an existing .piquette/piquette.config.yaml and asks, block by block (coding agent, quality, receipts, watch, notifications, advanced), which one(s) to reconfigure — defaulting to "yes" for a block that's already configured and "no" for one that isn't, so hitting Enter through the picker touches nothing. Only the block(s) you pick ask anything; every other block's current value — including secrets already in .env — is carried through byte-for-byte. It's interactive-only (no --yes/--template) and needs an existing config to reconfigure; run plain piquette init first on a fresh repo.
Without an interview, piquette init writes the same small starter .piquette/piquette.config.yaml, commented, that merges on top of the packaged built-ins field by field. --template <name> writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after piquette init runs, and the same files live in assets/templates/ to browse directly. Nothing here needs to exist for piquette to run; it's how you make one repo's roster diverge from the defaults.
Two templates demonstrate a pattern rather than one backend: tiered (piquette init --template tiered) runs scout/planner/builder on a local Ollama model and gives the reviewer a frontier hosted model with an adversarial stance, plus a defaults.max_run_cost cap — cheap proposes, expensive disposes. jira-bitbucket (piquette init --template jira-bitbucket) is the split-provider piquette watch pairing — Jira issues, Bitbucket PRs — with the repo vs issue_repo distinction, the JIRA_*/BITBUCKET_* env keys, and the refine-lane provider requirement spelled out in comments.
Every piquette init run — interview or not — also installs the repo-local Claude Code skill (.claude/skills/piquette), the same work piquette install-skill does by hand: pass --no-skills to skip it. It's idempotent (a no-op once the skill is already current, and writes a .new sibling instead of overwriting a file you've locally edited), so re-running piquette init never clobbers anything there. piquette install-skill --user (installing to ~/.claude/skills/piquette instead) is still its own separate invocation.
Local development
Working from a clone instead of the published package:
git clone [email protected]:piquette-ai/engine.git piquette-cli
cd piquette-cli
npm install
npm run build # builds both the CLI (dist/) and the trace visualizer SPA (web/)
npm link # makes `piquette` resolve globally to THIS checkoutnpm link beats a real npm i -g here: it symlinks the global piquette bin into this checkout, so every later npm run build takes effect immediately, no re-linking. Run it from any repo you want to test against.
npm run typecheck # tsc --noEmit
npm test # node --test against dist/**/*.test.js (runs build:cli first)
npm run size-check # tarball size gate — what `npm pack` would actually publishFor a one-off check without touching global npm state at all, run the built CLI directly: node dist/cli/bin.js doctor --cwd /path/to/repo. When you're done with the linked version, npm unlink -g @piquette/engine removes it.
Why this exists
Hand a capable model your whole SDLC and you get a machine with no seams. There is no phase boundary, so you cannot say which step failed. There is no acceptance criterion you can name, so "done" means "the agent stopped talking." A retry is a cold start that throws away everything the agent just learned. The only trace is a transcript you have to read like a novel. Run it twice, get two different systems.
The fix is not a better prompt. The fix is deciding, deliberately, that code owns sequencing, retries, and acceptance, and the agent owns only the work inside one bounded phase. Everything else falls out of that one line. Phases become the unit of the trace. Envelopes become the only way context crosses a seam. Gates become the definition of done. A correction becomes cheaper than a restart, because the session is still alive.
Agents are great. You do not always need one.
This is the part most engineers are going to skip, and pay for later.
Code costs nothing. It runs at the speed of light. You can change it in a second. And you actually own it, which is not true of any model you are renting by the token.
So when the invocation is already known, write it down. npm test is not a judgement call. Neither is tsc --noEmit. An agent rediscovering your test runner burns a context window to learn what a subprocess already knows, and it charges you for the privilege every single run. Worse, it puts a passing test suite into a context window, which buys you nothing at all.
Agents are for the parts that need reading and deciding. Everything else is a kind: "code" phase — PIQUETTE's own quality: config section names the commands, and a chain runs them as code, not as an agent's rediscovery project. When code fails, the failure comes back to the builder as an envelope, through the same door an agent's report would have used. The repair loop is identical. You just stopped paying an agent to do arithmetic.
The bill for skipping this is not only tokens. It is cost, speed, and consistency, and you pay it on run one hundred and run one thousand, not on run one.
Same models. Same prompts. The difference is who owns the loop.
The agent roster
piquette.config.yaml answers one question per entry: who is this agent. One agent, one prompt, one purpose. piquette init seeds .piquette/piquette.config.yaml, merged on top of the packaged defaults — an entry only states what differs.
defaults:
coding_agent: flue # the engine agents run on
model: google/gemini-3.6-flash # provider/model-id — a bare id can match several providers
thinking: medium # off | minimal | low | medium | high | xhigh | max
protected_files: [.piquette/, piquette.config.yaml] # no agent may edit the machinery that grades it
data_dir: .piquette/data
agents:
- name: planner
model: fireworks/accounts/fireworks/models/kimi-k3
thinking: high
color: "#a78bfa" # this agent's lane color in the trace UI
purpose: Turn a request into a plan the builder can implement without asking questions.
prompt_engineering:
system: planner/system.md
user: planner/user.md
writes: [specs/] # the plan is all it may leave in the repoSix starter agents ship in the box: planner, builder, scout (read-only recon), refiner (decomposes a product spec for piquette watch's refine lane — see "piquette watch" below), reviewer, and documenter. There is no tester, because running a suite is a known command and therefore code, not an agent's job.
Every agent gets its own model, thinking level, prompts, and tools. Give the planner a frontier model and the builder a cheap fast one. Give the reviewer no ability to write code at all.
tools is a capability list. writes is the boundary. They are not the same thing: bash runs anything, including git checkout, and write reaches any path. So "this agent changes nothing" is enforced in code, after every call, by comparing the repo before and after. Unauthorized changes are rolled back and the phase fails. A read-only agent is read-only with respect to your repo, never unable to write its own report.
Config defines who an agent is. The chain call site defines how it is used. That split is what lets one agent serve many different calls. Chains name agents, never models.
A second backend: Claude Code
Set coding_agent: claude_code on any agent (or in defaults) to run it on your own installed Claude Code CLI instead of Flue — piquette doctor checks whatever PIQUETTE_CLAUDE_CMD's first token resolves to on PATH (claude itself, by default). Model names follow Claude Code's own vocabulary (a bare alias like sonnet, not provider/model-id); everything else — tools, writes, thinking — stays the same shape. Pointing a claude_code agent at a local or cloud Ollama server needs no config at all — just ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN set before you run piquette, since Claude Code's CLI reads those itself.
Proxy or wrapper launchers
To route the claude command through a wrapper, proxy server, or launcher (e.g., Ollama), set PIQUETTE_CLAUDE_CMD — as a declarative env: entry in piquette.config.yaml, since it's a launcher choice, not a secret (see Declarative env vars below), or as a plain shell export for a one-off override. Space-separated command chains are supported:
env:
PIQUETTE_CLAUDE_CMD: "ollama launch claude --model {model}"piquette build "your prompt"ollama launch <cmd> uses cobra flag parsing, which treats anything typed after it as its own flags unless a literal -- says otherwise — without one, claude's own flags (-p, --json-schema, ...) fail with unknown shorthand flag: 'p' in -p before claude is ever reached. agent_cc.ts detects exactly this ollama launch ... shape and inserts that -- automatically, so you never add the separator by hand for this specific launcher.
That -- alone is not enough to reach claude, though: ollama launch also needs its OWN --model <tag> flag, typed BEFORE the auto-inserted --, whenever it runs headless — which it always does under PIQUETTE, since PIQUETTE spawns with piped stdio. Without it, ollama launch falls back to an interactive model picker that can never run, and fails one step later than the -- problem, with model selection requires an interactive terminal; use --model to run in headless mode. A --model typed after the -- doesn't help — at that point it belongs to claude, not to ollama launch. So PIQUETTE_CLAUDE_CMD must include ollama launch's --model yourself; piquette doctor hard-fails if it's missing.
Per-agent model choice: a literal {model} token in PIQUETTE_CLAUDE_CMD is substituted with each call's own agent-config model: field before spawning. Since ollama launch's --model (its OWN flag) is what actually pins the model serving the whole launched process — not claude's own --model, appended after the -- — a FIXED tag in PIQUETTE_CLAUDE_CMD means every claude_code agent runs on that one model regardless of what each agent's model: says. Using {model} instead makes it genuinely per-agent:
env:
PIQUETTE_CLAUDE_CMD: "ollama launch claude --model {model}"
agents:
- name: planner
coding_agent: claude_code
model: kimi-k2.7-code:cloud
- name: builder
coding_agent: claude_code
model: qwen3-coder:cloud# Or use a custom wrapper script
export PIQUETTE_CLAUDE_CMD=/path/to/my-wrapper
piquette build "your prompt"The command/launcher must support the full Claude Code CLI interface: -p for prompt, --json-schema, --model, --session-id/--resume, --output-format stream-json, and all other flags agent_cc uses. When unset, PIQUETTE_CLAUDE_CMD defaults to claude (resolved from PATH normally). A cmdSpec that already contains its own literal -- is left completely alone — agent_cc.ts never inserts a second one.
Declarative env vars
piquette.config.yaml supports an env: block for settings like PIQUETTE_CLAUDE_CMD that are configuration, not secrets — so they can be committed as plain, reviewable text instead of hidden in an out-of-band shell export:
env:
PIQUETTE_CLAUDE_CMD: "ollama launch claude --model {model}"
SOME_TOKEN: "${SOME_SECRET_FROM_DOTENV}"Applied to process.env before any command runs. A plain value commits as literal text; ${VAR} interpolates from whatever's already in process.env at that point (the real shell, or .env, both of which load first) — so a real secret can live in .env (gitignored) and be referenced here without ever being written into this file. An already-set process.env value always wins over env:'s — piquette.config.yaml supplies the default, a real export still overrides it per machine/session. A ${VAR} reference to something that's genuinely unset fails loudly at startup, naming the missing variable, rather than silently interpolating to an empty string.
A third backend: opencode
Set coding_agent: opencode on any agent (or in defaults) to run it on your own installed OpenCode CLI instead of Flue or Claude Code — install it with npm install -g opencode-ai (the package is opencode-ai; the binary it puts on PATH is opencode). piquette doctor checks that opencode resolves on PATH and that ~/.local/share/opencode/auth.json exists, the same informational, not-enforced posture as its claude_code check for ANTHROPIC_API_KEY. Model names follow the same provider/model-id shape Flue uses (e.g. anthropic/claude-sonnet-4-20250514, ollama/qwen3-coder:30b) — not Claude Code's own bare-alias vocabulary (sonnet, opus, ...); everything else — tools, writes, thinking — stays the same shape.
Authentication is handled entirely outside piquette: run opencode auth login once (interactive), or opencode auth list to check non-interactively what's already configured. piquette never drives this itself.
Like claude_code, routing the opencode command through a wrapper or launcher uses the equivalent PIQUETTE_CLAUDE_CMD-style env var for this backend, with the same {model} token-substitution mechanic — see Declarative env vars above and check opencode --help for the flags a wrapper needs to forward.
flue + local Ollama
Point the default flue backend at a local Ollama server the same way you'd pick any other Flue provider — the model string's own prefix, ollama/<tag> (whatever ollama list shows on your machine) instead of openai/.../anthropic/...:
defaults:
coding_agent: flue
model: ollama/qwen3.8:27b-mlxexport OLLAMA_BASE_URL=http://localhost:11434/v1 # default if unset
piquette build "your prompt"That's the whole config change. ollama is a keyless provider — piquette doctor and the piquette init interview both know this: no API key is ever asked for or checked, and interview.ts asks for OLLAMA_BASE_URL instead when you pick ollama as your provider. A ready-to-run starting point ships at assets/templates/ts-flue-ollama.piquette.config.yaml (piquette init --template ts-flue-ollama).
Two things worth knowing before pointing a full roster at local models: tool-calling — the injected sf_report contract every agent's structured output rides on — worked reliably in testing down to a 3B-parameter model, which isn't a "only frontier models get tools" situation. And context-window occupancy reporting is disabled for ollama/* models specifically, which turns off threshold-based compaction rather than reporting a number Ollama's OpenAI-compatible API doesn't actually provide per model.
flue + Cloudflare Workers AI
Point the default flue backend at a Cloudflare Workers AI model the same way you'd pick any other Flue provider — the model string's own prefix, cloudflare/<model-id>, where <model-id> is the full @cf/... catalog id (the slash inside the id is fine; piquette splits on the first slash only):
defaults:
coding_agent: flue
model: cloudflare/@cf/zai-org/glm-5.3-flashexport CLOUDFLARE_ACCOUNT_ID=your-account-id # derives https://api.cloudflare.com/client/v4/accounts/$ID/ai/v1
export CLOUDFLARE_API_TOKEN=your-token # Workers AI > Read permission
piquette build "your prompt"That's the whole config change. cloudflare is a keyed provider (a real Bearer token, unlike keyless ollama — Cloudflare's API 401s on an empty Authorization header). piquette init asks for the token plus either CLOUDFLARE_ACCOUNT_ID (the standard endpoint) or an explicit CLOUDFLARE_AI_BASE_URL (a custom domain or an AI Gateway endpoint), and overrides the three packaged-roster agents that a Workers AI account can't serve. piquette doctor probes the resolved endpoint (informational, never a hard failure). A ready-to-run starting point ships at assets/templates/ts-flue-cloudflare.piquette.config.yaml (piquette init --template ts-flue-cloudflare).
Two things worth knowing, both inherited from the ollama/* convention for self-served OpenAI-compatible endpoints: context-window occupancy reporting is disabled for cloudflare/* models (contextWindow: 0, turning off threshold-based compaction rather than fabricating a number Workers AI's API doesn't provide per model), and reasoning: false — the model's own reasoning_effort parameter is not wired through, so an agent's thinking: level is moot for cloudflare/* models today.
claude_code + Cloudflare AI Gateway
To route the claude_code backend through a Cloudflare AI Gateway (so the gateway holds the Anthropic credentials via Unified Billing or BYOK, and you authenticate to the gateway), pick Cloudflare AI Gateway at the Authentication step in piquette init. piquette writes the three env vars the gateway needs:
ANTHROPIC_BASE_URL=https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_ID>/anthropic
ANTHROPIC_API_KEY=<CF_AIG_TOKEN> # any value — the gateway ignores it; Claude Code requires it set
ANTHROPIC_CUSTOM_HEADERS=cf-aig-authorization: Bearer <CF_AIG_TOKEN>piquette doctor probes the gateway's /v1/messages with the cf-aig-authorization header (informational, never a hard failure). This is the claude_code backend — it speaks the Anthropic API, so it's independent of the Workers AI flue provider above; you can run some agents on Workers AI (flue) and others through the gateway (claude_code) in the same roster.
Isolation: post-hoc, not a sandbox
Agent isolation in PIQUETTE is enforced after the fact, not upfront. This matters because the tools: capability list is not a sandbox — a bash tool can run git checkout to discard changes, and a write tool can reach any path, regardless of the allowlist you write. So permission is verified the way every other claim in this system is: the working tree is fingerprinted before an agent runs, then compared after. If the agent touched anything outside its writes: allowlist (or a protected_files: path it wasn't given access to), the phase fails and anything the agent introduced outside its allowlist is rolled back. What it cannot undo, it names: a file that was already dirty before the agent ran is left alone rather than discarded, and uncommitted work the agent reverted cannot be restored. And because the check is post-hoc and tree-scoped, nothing outside the working tree is in scope at all — a push, a network call, a write above repo_root. When running an agent on the claude_code backend, PIQUETTE spawns it with --permission-mode bypassPermissions --dangerously-skip-permissions because the backend has no need to enforce — PIQUETTE will enforce in code instead, within those bounds.
One narrow, explicit exception: defaults.read_only_ignore (default: the lockfiles of npm/pnpm/yarn/bun) names paths a true read-only agent (writes: []) may touch without failing the phase — a read-only agent that runs a dependency-manager command to inspect a package's real shape rewrites its lockfile as a side effect of a read, not an edit. The path is still always rolled back; the phase only passes when the path was clean before the phase started (so the rollback is a real restore) — a path already dirty when the phase began fails exactly like any other breach, whether the agent left it modified or reverted an operator's own uncommitted work. And the exemption is scoped to true read-only roles only: a write-restricted agent (writes: [...]) that changes a lockfile alongside something it was given (say package.json) left an inconsistent tree, not incidental bookkeeping, and fails like any other breach.
protected_files always takes precedence over read_only_ignore: a path matching protected_files is never exempt, even for a true read-only agent, even when it also happens to match a read_only_ignore pattern. read_only_ignore is a narrow "this churn is incidental" carve-out for dependency-manager bookkeeping — it is never a backdoor around a path an operator explicitly locked down with protected_files. That path is still rolled back like any ignored churn, but it fails the phase like any other breach.
For unattended work, piquette watch's PR-merge is the only human accountability checkpoint: the daemon opens a PR for each automated run, and a human approves the merge. For any other workflow, this enforcement is the backbone of letting a bounded agent proposal survive code's inspection without a human having to read it in between — but it is bounded, not a sandbox, and the scope above is what a reviewer should actually rely on. (Attended simple-sdlc runs get a second checkpoint of their own — see below.)
See core/permissions.ts for the implementation — the snapshot/compare logic, not the config terms.
simple-sdlc's commit_build phase is the one place in this codebase where an AI reviewer's approved flag alone would otherwise gate a commit — everywhere else the reviewer either doesn't run (plan-build-test, ...) or nothing commits on its verdict (build-review has no commit step). Run attended, that phase asks before committing — default no, never review.approved itself, bounded by review.signoff_timeout_seconds (default 300s; expiry answers no) — and only an explicit, recorded "yes" ever earns a Signed-off-by: trailer, built from git config user.name/user.email at the repo, never an env var. Run unattended (piquette watch, CI), there is nobody to ask: piquette watch's own human gate is the PR merge above, informed by the reviewer's digest in the PR body, and the phase either proceeds on the AI verdict alone with a loud warning (review.require_human_signoff: false, this release's default — fail-open until piquette watch itself becomes signoff-aware) or fails closed and asks you to rerun attended (require_human_signoff: true). See assets/skill/references/config.md's review section for the two knobs, and assets/skill/cookbooks/ocr_reviewer.md if you want the reviewer to fold a third-party review tool's findings into its own verdict as one more piece of evidence.
Phases: three lanes, one primitive
Every run is a sequence of phases, and every phase is the same async scope no matter who owns it.
const REQUIRED_AGENTS = ["planner", "builder"]; // names, never models
const cfg = agents.loadConfig(configPaths);
agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd); // a missing agent fails before anything spawns
const run = session.ensure(cfg, adwId, cwd); // pin-or-create the session
const plan = await run.phase(
makePhaseParams({ name: "plan", kind: "agent", owner: "planner", description: "Turn the request into a plan" }),
(ph) => ph.call(makeAgentCall({ output_type: PlanOutput, prompt, gates: [gates.artifactsExist] })),
);
await run.phase(
makePhaseParams({ name: "commit", kind: "code", owner: "git", description: "Commit the working tree" }),
async () => run.git.commitAll(build.commit_message || `piquette: ${prompt.slice(0, 72)}`),
);
return run.finish();Three kinds, three swim lanes. engineer is the human lane. agent is ph.call(...): prompt in, typed envelope out, gates verified. code is a deterministic step that stands on its own — a commit, a quality suite — and is never buried inside an agent phase, so the trace shows exactly when code ran and when an agent was working.
Success must be earned. Every phase defaults to fail. A clean exit flips it, and an agent phase also needs its envelope to parse and every gate to come back green. run.finish(accepted, reason) adds the second question, because phases passing is not the same as the run being acceptable: a test phase that ran a red suite did its job perfectly. One call settles the exit code, the session status, and the banner together, so they cannot disagree.
Envelopes and gates
An agent has exactly two output channels: reference files written into context_handoff/, and a final valid-JSON response validated against the output type the call declared. Code persists that response as envelope.json, records it, and injects it into the next agent's prompt. Context transfers in code, not in conversation.
const BuildOutput = envelopeType("BuildOutput", {
changed_files: v.optional(v.array(v.string()), () => []),
commit_message: v.optional(v.string(), ""), // consumed by the git commit phase
});Determinism is wired into every step. Agents must return a specific structure, every time. If it does not validate, they get asked again until it does.
Gates verify claims, never predictions. Nobody knows which files an agent will touch before it finishes, so gates run after the fact against the envelope's own declarations: artifactsExist, filesNonEmpty, jsonParses, diffMatchesClaims, testsPass(...). A gate is a function with the signature gate(envelope, run) -> GateReport, one check(item, ok, note) per thing it examined, so a green gate tells you what it verified.
When validation fails or a gate returns violations, nothing restarts. The harness re-prompts the same session with a correction naming exactly what was wrong, and the context window stays intact. A cold restart throws away everything the agent learned. A correction costs one message.
The output contract lives in three places and they are one thing: the type in data_types.ts, the JSON example in that agent's user.md ## Report section, and output_type: at the call site. Change one, change all three in the same edit — the synced triad.
The trace
One data path, no exceptions: agents write to SQLite, readers poll SQLite. Every event lands in .piquette/data/piquette.db while the agent is still working, not batched at the end — tool calls are visible mid-run.
piquette sessions # recent runs
piquette phases <adw_id> # phase-by-phase status for one run
piquette events <adw_id> --follow # the live trace, tailable
piquette decisions calibrate # read-only: recorded decisions vs observed outcomes, suggested thresholds
piquette feedback <adw_id> "missed the migration" # annotate a run; lands as a `feedback` event
piquette ui # a browser-based visualizer over the same dbThat's the entire transport: a rowid-cursor poll query, run on demand. No push, no WebSocket, no ingest endpoint — live view and full history are the same query at different cadence.
Files stay the raw record (envelope.json, agent_map.json, Flue's own conversation store). The db is the queryable mirror. Losing it loses nothing you cannot rebuild.
The chains
piquette list # every chain, its phases, what it needs
piquette <chain> "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4] [--cwd <dir>] [--suite <name>]| Chain | Phases | Reach for it when |
|---|---|---|
| prompt | engineer → <agent> | one agent, one prompt, --agent NAME picks who |
| scout | engineer → scout | read-only recon, nothing changes |
| plan | engineer → planner | you want the spec before any code |
| build | engineer → builder | the plan already exists |
| quality | engineer → code(quality) | lint, typecheck, build, no agents at all |
| plan-build | planner, builder, git(commit) | small, well-understood work |
| build-test | builder, code(test), bounded fix loop | there is a suite to satisfy |
| build-review | builder, reviewer, bounded revise loop | "is this what was asked for" matters more than "does it run" |
| plan-build-test | plan, build, code(test), git(commit) | the standard chain |
| plan-build-test-quality | same, plus lint/typecheck/build gates | the repo has quality commands worth enforcing |
| document | code(git diff), documenter | write up what just shipped |
| simple-sdlc | plan, build, test, review, document | the work is real and its shape is not obvious |
A chain with a compiled-in quality suite (build-test, plan-build-test, plan-build-test-quality, quality) accepts --suite <name> to override it for one run — piquette list marks which chains this applies to with (--suite overrides). simple-sdlc's suite is not overridable this way; passing --suite to it is rejected rather than silently ignored.
--adw-id is optional everywhere. Omit it and a fresh id is minted and printed. Supply it and the run joins that session — same session directory, each agent resumes its existing context window instead of starting cold. That's how you chain runs:
piquette plan "add a /health endpoint" # prints adw_id a1b2c3d4
piquette build-test "implement the plan" --adw-id a1b2c3d4Repo-local chains
Every chain above is built in. A target repo can also compose its own out of
the same step factories, with zero forking and zero build step, by dropping a
file in .piquette/chains/*.yaml — piquette init scaffolds .piquette/chains/example.yaml
(fully commented out) showing the shape, plus .piquette/chains/review-fix.yaml
showing how to put an external CLI (e.g. a code reviewer like
open-code-review, or a linter) into the sequence as a gated fix loop:
# .piquette/chains/ship-it.yaml — piquette ship-it "<prompt>" / piquette run ship-it "<prompt>"
name: ship-it
describe: plan, build, test, land — with our own reviewer in the loop
route_when: Use for feature work in billing/ that must get our own reviewer's eyes. Not for docs or config-only edits. # optional
steps:
- step: request
- step: plan
owner: architect
- step: build
extraGates: [jsonParses] # additive only — a step's built-in gates can never be removed
- step: fixLoop
suite: test
- step: commit
onlyIfAccepted: trueroute_when is optional: one or two sentences telling piquette watch's decision-layer chain router when to pick this chain (it reads that instead of describe, which only says what the chain is; see docs/decisions.md, "chain_router"). Built-in chains carry one too, and watch.route_when in piquette.config.yaml overrides either.
It shows up in piquette list/piquette doctor exactly like a built-in, and runs on
the identical stepChain() driver — there is no second interpreter. The full
step vocabulary, the gate rules, and the piquette watch main-anchor divergence
(a chain edited on an issue branch is not what an in-flight piquette watch run
uses) are documented in the installed skill's "Repo-local chains" section
(.claude/skills/piquette/cookbooks/authoring_chains.md, after piquette init).
piquette watch
Polls an issue tracker for issues labeled <prefix>:ready, runs a configured chain against each in its own git worktree, opens a PR against a code host, and tracks it through to merged or blocked — driving the same chains above rather than reimplementing an SDLC. For issue_provider: github and jira, labels are the whole state machine: ready → working → review → done/blocked, plus blocked ⇄ feedback — see "Revising a rejected (or still-open) PR" below. For issue_provider: github-projects, a GitHub Projects v2 board's own Status field drives that same state machine instead — see "GitHub Projects v2" below.
A second, optional lane (watch.refine, off by default) decomposes a <prefix>:spec-ready product spec into a feature/story-or-bug tree of real issues instead — see "Refining specs" below.
The tracker (issue_provider) and the code host (code_host) are independent config choices, not one bundled "provider" — a tracker and a host are independent choices in practice (Jira issues against a Bitbucket repo is a real setup). Supported today: issue_provider: github | github-projects | jira, code_host: github | bitbucket — any combination works, including Jira+GitHub, GitHub-issues+Bitbucket, or github-projects+Bitbucket (a board of GitHub issues, PRs opening against a separate Bitbucket repo — the same issue_repo split as GitHub-issues+Bitbucket, below).
The easiest way into any of this is piquette init's interview: it asks whether to enable piquette watch, which tracker and code host, and collects exactly the env vars that combination needs (below) straight into .env — no hand-editing YAML or hunting down which credential pair a given combination wants.
# .piquette/piquette.config.yaml — GitHub issues + GitHub PRs (the default)
watch:
issue_provider: github # default
code_host: github # default
repo: owner/name
label_prefix: piquette # polls issues labeled piquette:ready
chain: plan-build-test # any registered chain
base_branch: main
poll_ms: 60000
concurrency: 2# .piquette/piquette.config.yaml — Jira issues + Bitbucket PRs
watch:
issue_provider: jira
code_host: bitbucket
repo: workspace/repo_slug # Bitbucket's own two-part identifier
label_prefix: piquette # polls Jira issues labeled piquette:ready
chain: plan-build-test
base_branch: main
jira:
base_url: https://your-domain.atlassian.net
project_key: PROJ# .piquette/piquette.config.yaml — GitHub issues + Bitbucket PRs, in TWO DIFFERENT repos
watch:
issue_provider: github
code_host: bitbucket
repo: workspace/repo_slug # Bitbucket's repo — the CODE HOST always reads plain `repo`
issue_repo: owner/name # GitHub's repo — only needed here, where issue tracker != code host
label_prefix: piquette # polls GitHub issues labeled piquette:ready
chain: plan-build-test
base_branch: main# .piquette/piquette.config.yaml — a GitHub Projects v2 board drives intake; PRs open on the same repo
watch:
issue_provider: github-projects
code_host: github
repo: owner/name
label_prefix: piquette # still used: type/priority labels, and any state left unmapped below
chain: plan-build-test
base_branch: main
github_projects:
project_number: 3 # the owner's board number — see the board's own URL
status_map: # these five are REQUIRED — the build lane's own board columns
ready: Todo
working: In Progress
review: In Review
done: Done
blocked: Blockedrepo always names the code host's own repo; issue_repo overrides it for
the issue-tracker side. This only matters for issue_provider: github or
github-projects + code_host: bitbucket — the combinations where they're
genuinely different repos in different systems, not the same repo worn two
ways (github+github is one repo by construction; issue_provider: jira
never reads repo at all, so it's never ambiguous). Leave issue_repo
unset everywhere else. piquette doctor flags this specific combination when
issue_repo is missing, since the silent failure mode — polling GitHub with
the Bitbucket identifier — finds no matching issues and looks exactly like
nothing being configured at all.
piquette watch init # idempotently seed tracker state (labels are a no-op report on Jira — see below); run this first
piquette watch # foreground daemon; Ctrl-C drains in-flight claims first
piquette watch --once # one poll tick, then exit — good for cron
piquette watch --dry-run # log intended claims/transitions, mutate nothingNo GitHub App, no webhook, no Jira/Bitbucket app install — it's a plain REST poll against whichever combination is configured, same philosophy as the trace db's own polling contract. See assets/templates/ for full worked configs (also usable directly via piquette init --template <name>), and piquette install-skill's installed skill (roster.md, references/config.md) for the field-by-field reference.
watch.allowed_authors — the label isn't the trust boundary
<prefix>:ready is a label, and on GitHub only accounts with Triage+ access can add one. But on a repo that accepts public issues, ANYONE can open an issue and write whatever they want in its body — labeling and authoring are different permissions, held by different people. A collaborator who labels a plausible-looking public issue ready without catching an embedded instruction hands that issue's body to a coding agent with real Bash/write access as if it were trusted input. The label gate alone doesn't stop this, because the person who can label and the person who wrote the content aren't the same person.
watch:
allowed_authors: [octocat, some-other-trusted-login] # empty (default) = unrestrictedEmpty (the default) is unrestricted — identical to every piquette watch release before this field existed. A non-empty list is a hard refusal, checked against Issue.author (the account that OPENED the issue, never whoever most recently labeled or edited it): an issue from anyone else is transitioned straight to blocked with an explanatory comment, before a worktree, a lock, or a chain run ever exist for it. Re-labeling it ready again does nothing — the fix is either adding that author to the list, or opening an equivalent issue yourself. piquette doctor reports whether this is set.
This applies to both lanes — claimNewWork (<prefix>:ready) and claimSpecs (<prefix>:spec-ready/continue-refinement). It does not get in your own way: an issue the refine lane creates itself (a spec's decomposed children) carries whichever tracker identity your own configured credential creates issues as, so if you're in your own allowed_authors list (the common case — you're the one who filed the spec), its children pass too.
Priority, dependencies, and picking what's next
Among every <prefix>:ready issue, piquette watch claims in this order:
- Priority — the
<prefix>:priority:p0|p1|p2|p3label (p0drop-everything,p2the default,p3someday).piquette watch initseeds all four. A human relabeling an issue is the whole override mechanism — there's no separate priority command. - Sibling affinity — among issues at the same priority, one whose parent feature already has a sibling in flight goes first. This is what tends to finish one feature before starting the next without giving up the one-PR-per-story design (see "Refining specs" below): each story is still its own claim, its own worktree, its own PR — affinity only orders which claim happens next.
- Creation order (oldest first) — the final tiebreaker.
Before claiming anything, piquette watch also checks the frontier: an issue's blocked_by dependencies (set by the refine lane, or by hand) must all carry <prefix>:done first. A leaf whose blockers aren't done yet is skipped, not blocked — it's simply reconsidered next tick, once the log line naming what it's waiting on stops applying.
This is a label, not this repo's own GitHub Projects v2 "Priority" field (if your board has one — Urgent/High/Medium/Low, say) — including on issue_provider: github-projects, where the board's Status field drives the state machine but Priority is still deliberately read from a label, never from the board (no Jira equivalent, and a Projects v2 value would make priority mean something different depending on which tracker is configured). If you use both, they're independent — nothing reconciles them, and piquette watch obeys only the label. Keep them aligned yourself, or don't use the board field for this repo's issues.
When the push is rejected (repo hooks)
A failed run's work is committed and pushed to <branch>-wip before its worktree is removed. When a git push fails anyway (most often a repo pre-push hook running the project's own tests), the blocked comment includes the last 50 lines of what git printed, stdout and stderr both, so the hook's report is visible. If the -wip push is rejected too, the commits are kept locally on piquette-failed/<id>-<slug>-<sha> in the watcher's repo, and the comment gives the git push that recovers them. A hook runs after the chain has finished, so the chain's bounded fix loop never sees it. To give the builder that loop, add the hook's command (for example npx lefthook run pre-push) to quality.checks and to the suite your chain's fixLoop runs.
Revising a rejected (or still-open) PR (<prefix>:feedback)
A declined PR used to be a dead end: relabeling the issue back to ready threw away the PR entirely and started over from origin/<base> with the original issue text — no memory of what was wrong, and (until this label existed) a git push non-fast-forward rejection against the still-live remote branch. <prefix>:feedback fixes both: leave your corrections as comments on the PR itself (not the issue), add the label, and piquette watch reruns the same issue with that PR's comment thread folded into the prompt.
review ──(you decline the PR)──→ blocked "leave corrections as comments on PR #N,
│ then add <prefix>:feedback"
▼
feedback ──claim──→ working ──┬─→ PR still open: rebuild from the PR's
│ own branch head, push, SAME PR updates
└─→ PR closed: fresh branch, new PR
superseding the old one
→ reviewNo decline required first: add <prefix>:feedback to an issue whose PR is still open (leave a review comment, then label it) and piquette watch rebuilds from that PR's own branch head and pushes the fix onto the same PR — no new PR number, just a new commit landing on what's already under review. That's the primary path this exists for. A PR that was actually closed/declined instead gets a fresh -rN-suffixed branch and a new PR whose body notes it supersedes the old one, since there's no branch head left to build on top of.
Corrections come from PR comments, not issue comments — deliberately a different channel from the refine lane's continue-refinement loop above, matching where a human actually reviews code. Reading them needs CodeHostProvider.listPrComments, implemented for both GitHub (issue comments + inline review comments + review verdicts, merged into one thread) and Bitbucket; a code host without it degrades honestly — the issue blocks again with an explanation instead of silently rebuilding with no corrections.
piquette watch init seeds <prefix>:feedback alongside the other state labels — re-run it after upgrading to this version.
Refining specs (watch.refine)
A product spec isn't individually workable — it needs to become a feature,
broken down into user stories and bugs, before the build lane above has
anything to claim. watch.refine is a second lane over the same poll loop
that does exactly that: it polls <prefix>:spec-ready, runs a decomposition
chain (refine by default — request → scout → refiner → publish) against
the spec in its own worktree, and publishes what it produces as real tracker
issues — a feature/epic container plus story/bug/task leaves, linked into a
real hierarchy: GitHub's native sub-issues API, or Jira's parent field.
watch:
issue_provider: github # or jira — both implement issue authoring
repo: owner/name
refine:
enabled: true
chain: refine # any chain that ends in steps.publishIssues()
concurrency: 1 # this lane's own budget, independent of watch.concurrency
max_leaves: 4 # optional — shown are the defaults; the decomposition BUDGET, see below
max_nodes: 6
max_depth: 2(A third, independent budget lives at watch.fanout.concurrency — best-of-N
attempts per claimed issue, not specs. See "Best-of-N per issue" below.)
The decomposition budget. Every leaf the refiner emits becomes its own
worktree, its own chain run, and its own pull request a human reads once
promoted — so leaf count is human review count. assets/prompts/refiner/
system.md targets one feature and three or four leaves; refine.max_leaves
(default 4), refine.max_nodes (default 6), and refine.max_depth (default
2) are the hard CEILINGS gates.refinementWellFormed enforces on top of that
target, one slice past where a good decomposition sits so the gate never
makes the target a floor. max_nodes doesn't bind at the defaults — it
exists so raising max_leaves or max_depth alone can't silently uncap the
whole tree. Raising max_depth above 2 is unsafe on Jira specifically: see
"On Jira" just below. A tree with a container wrapping exactly one child also
fails — a container that groups nothing is pure ceremony — and every leaf
must carry a one-sentence user_outcome (rendered as a ## Outcome section
on the tracker issue) naming who benefits and what changes for them; a
process/QA/rollout leaf has no honest sentence to write and gets folded into
whichever leaf's acceptance criteria it actually belongs to instead. See
"Splitting an over-large spec" below for what happens when a spec honestly
doesn't fit.
On Jira, every RefinedIssue.kind (epic/feature/story/bug/task),
plus spec (a standalone spec proposed by splitting an over-large one — see
"Splitting an over-large spec" below), maps to a real Jira issue type through
watch.jira.issue_types — defaults epic/feature → Epic, story/spec
→ Story, bug → Bug, task → Task, overridable per kind since real
projects rename or customize these:
watch:
issue_provider: jira
jira:
base_url: https://your-domain.atlassian.net
project_key: PROJ
issue_types: # optional — shown are the defaults
epic: Epic
feature: Epic
story: Story
bug: Bug
task: Task
spec: Story
refine:
enabled: trueHierarchy uses Jira's modern parent field only (no legacy "Epic Link"
custom-field support) — this works on team-managed projects and on
company-managed projects with Jira's current issue-hierarchy setting; a
project not configured for it surfaces Jira's own API error, unmodified.
One accepted platform limitation: Jira doesn't support Epic-under-Epic
nesting the way GitHub's sub-issues API supports up to 8 levels, so a
feature node parented under another epic/feature (both Epic by
default) will fail at publish time on Jira specifically — a real platform
difference, not a bug. refine.max_depth's default of 2 makes this
unreachable in practice (see "The decomposition budget" above); raise it
past 2 only on a tracker where deeper nesting is actually supported. A spec
never parents another spec either way — see "Splitting an over-large spec"
below. Both piquette watch init and piquette watch's own startup check validate
watch.jira.issue_types against the real project before anything unattended
runs — see "Jira" below.
Every generated issue carries a <prefix>:type:epic|feature|story|bug|task
label AND a <prefix>:priority:p0|p1|p2|p3 label (see "Priority, dependencies,
and picking what's next" above) — the refiner proposes the priority, a human
can relabel it before promoting. A container (a feature/epic — something
else names it as parent) gets only those two labels; a leaf additionally
gets <prefix>:refined — never <prefix>:ready. Promoting a leaf to
<prefix>:ready is a deliberate human decision: the refine lane never
auto-promotes anything. (On issue_provider: github-projects, promote by
moving the card to the Ready column — the Status mapped to ready; that
overrides the <prefix>:refined label and the stale label is removed
automatically. No other label-only state is overridden by the board; piquette
watch logs a warning once if one shadows a build-lane Status.) This is the SECOND line of defense against a
decomposition nobody looked at before it turns into unattended chain runs and
PRs — the first is the decomposition budget itself (see above), which keeps
that number small (four leaves by default, not twenty) rather than relying on
manual promotion alone to absorb an oversized tree. Once you do promote a
leaf, the existing build lane picks it up completely unchanged — including
the frontier check: a promoted leaf isn't claimed until every issue named in
its blocked_by is <prefix>:done.
A spec issue's own <prefix>:priority:pN label, if it has one, reaches the
refiner as a ceiling: no generated node may be more urgent than the spec
itself (enforced regardless of what the refiner emits — see
core/refine.ts's publish()). piquette refine ... --priority p1 sets the same
ceiling for a bare manual run with no spec issue to read a label from.
Grounding the decomposition in real code is structural, not just prose: a
scout phase maps the subsystems the spec touches before the refiner ever
runs, and its findings flow straight into the refiner as context. This makes
scout a required agent for the refine chain — a roster that pruned it
fails piquette watch startup by name, the same as any other missing required
agent.
Human-in-the-loop escalation
The refiner is instructed not to guess on anything material (scope, data
model, an external dependency choice, a UX contract, a breaking change, or
anything that would contradict an existing ADR — see
assets/prompts/refiner/system.md's "Ask, don't decide"). When it hits real
ambiguity it raises questions instead of publishing a partial tree, and the
spec moves through an extra loop before it's even fully published:
spec-ready → refining ──┬─→ spec-in-progress → done / blocked (published a tree, THEN tracked to completion)
├─→ needs-feedback (raised questions)
│ │ a human answers in the issue's comments,
│ │ then adds continue-refinement
│ ▼
│ refining (again, same adw_id) → ...
└─→ split-proposed (spec too large — see below)Answer the refiner's questions as comments on the spec issue, then add the
<prefix>:continue-refinement label. piquette watch resumes the same
adw_id — the refiner's own coding-agent session continues, with the
comment thread folded into its prompt — so it never re-derives context it
already had, and it can loop through as many rounds as it takes; there's no
cap. If channels are configured (see "Notifications" below), a
spec_needs_feedback event fires every round.
Publishing a tree does not mean the spec is done. A product manager
watching a spec's status needs "done" to mean the work is actually finished,
not merely that a decomposition happened — so once the refiner publishes,
the spec moves to <prefix>:spec-in-progress, not <prefix>:done. It stays
there — with a summary comment listing every issue it created, noting how
many rounds of feedback it took, if any — until every one of those
issues reaches <prefix>:done (a leaf that lands, or a feature/epic
container once rollUp has already finished it — see "Container roll-up"
below). Only then does piquette watch transition the spec to <prefix>:done,
post a closing comment, and close it on the tracker where the tracker
supports closing at all — GitHub does (best-effort: a failed close never
turns completed work back into blocked); Jira doesn't have this wired up,
so a Jira spec still relabels and comments correctly, just stays open. A
spec_done event fires either way, distinct from the earlier
spec_refined. A spec whose
generated leaf never gets promoted, or whose work stalls blocked, simply
stays spec-in-progress — which is the truthful state, not a bug.
The spec issue's full lifecycle: spec-ready → refining → needs-feedback ⇄
refining → spec-in-progress → done/blocked (the needs-feedback ⇄
refining loop only when the refiner actually escalates), or, when the spec
is too large rather than ambiguous, spec-ready → refining → split-proposed →
... — see "Splitting an over-large spec" just below. Try it by hand first,
against a real spec, before turning on the daemon:
piquette refine "<spec text or path/to/spec.md>" --issue 42 # --issue renders a "## Parent" section, "Decomposed from #42."
piquette refine "<spec text or path/to/spec.md>" --issue 42 --priority p1 # --priority clamps every generated node to p1 or less urgentSplitting an over-large spec
Ambiguity isn't the only reason a refiner can't publish a tree this round — sometimes the spec is simply too big, honestly, for the decomposition budget above. Rather than force-fit an oversized tree (or worse, merge unrelated slices into a handful of leaves that hit the number but blur past what a reviewer can hold in their head), the refiner proposes a concrete split into two or more standalone specs — each with its own user value, each independently shippable, never "phase 1"/"phase 2" of the same thing:
spec-ready → refining ──→ split-proposed
│ a human reviews the proposal comment, then either
│ · adds split-approved → executed deterministically, NO agent re-run
│ · comments + continue-refinement → refiner revises the proposal
▼
N new specs created (spec-ready, type:spec), recorded
on the ORIGINAL spec, which moves to spec-in-progressApproving with <prefix>:split-approved is a deliberate, different label
from continue-refinement's "resume the refiner" — creating exactly what a
human already read in the proposal comment is a code-shaped action (no
scout, no refiner session, no worktree), the same way publishing a tree is.
Each created spec carries <prefix>:type:spec and <prefix>:spec-ready (never
<prefix>:refined — a spec is not itself a workable leaf) and a ## Parent
back-reference to the original spec it was split from; on Jira it maps to a
Story by default (watch.jira.issue_types.spec), never nested under
another spec, since Jira has no Story-under-Story hierarchy to use. The
original spec's own spec-in-progress tracking now waits on both child
specs finishing their own trees, transitively — the same roll-up mechanism
"Publishing a tree does not mean the spec is done" above already describes,
with no separate tracking needed for a split. A child spec that is itself
still too large can split again — a genuinely huge spec bottoming out over a
couple of rounds is expected; watch for repeated split proposals on the same
original spec as the signal something is off, rather than a hard generation
cap.
Container roll-up
A feature or epic isn't itself a unit of work, so it's never claimed and
never opens a PR — but it isn't abandoned once its children exist, either.
Every time a leaf's PR merges, piquette watch checks that leaf's parent: once
every child under a container carries <prefix>:done, the container
gets a summary comment (every child it rolled up, by id), transitions to
<prefix>:done, and closes on the tracker if the tracker supports closing
at all (GitHub does; Jira doesn't have this wired up, so a Jira container
still relabels and comments, just stays open) — then the same check runs on
its parent, so an epic of features rolls up once its last feature does.
A container with even one unfinished child (including one still sitting at
<prefix>:refined, never promoted) is left exactly as it is; nothing times
out or force-closes it.
Roll-up needs to read back the hierarchy publish() created — GitHub's
native sub-issues API, or a Jira parent = "<id>" JQL search — so it works
on both providers today, same as authoring itself. A tracker that isn't
authoring-capable at all gets a logged no-op, not a startup failure, since
the build lane still works fine without roll-up.
piquette watch init seeds the type labels alongside the state ones — re-run
it after upgrading to this version, so it can create the new
<prefix>:type:spec, <prefix>:split-proposed, and
<prefix>:split-approved labels, alongside the earlier
<prefix>:needs-feedback and <prefix>:continue-refinement. This lane's
prompt (assets/prompts/refiner/) is adapted from a "tracer-bullet ticket"
decomposition skill — vertical slices, a blocked_by dependency graph, and
an expand/migrate/contract sequence for wide mechanical refactors — with a
gate (gates.refinementWellFormed) added on top to enforce the container/
leaf shape that skill left as prose convention rather than a checked rule:
container/leaf kind agreement, no container with a single child, a depth
cap, a leaf/node count ceiling, a required per-leaf user-outcome sentence,
and that a refinement never publishes issues alongside questions or a split
proposal in the same round.
Best-of-N per issue (watch.fanout)
piquette fanout runs N sibling attempts of one prompt and lets code pick the
winner (pickBest: succeeded > fewest gate failures > most gate passes >
lowest cost > fewest tokens > lowest wall time > adw_id, see the piquette fanout
section above). watch.fanout is that same mechanism pointed at piquette watch's
per-issue dispatch: a claimed issue runs n sibling attempts instead of one,
and everything downstream of the winner — push, PR, review digest, marker,
transition, notify — is the same code that already runs for a single attempt.
watch:
repo: owner/name
chain: plan-build-test # must have a commit step once n > 1 — see below
concurrency: 2 # issues claimed and run at once — UNCHANGED meaning
fanout:
n: 1 # attempts per claimed issue. 1 (the default) = today's single dispatch, no-op for the daemon
concurrency: 2 # attempts of ONE issue's fan-out in flight — NOT watch.concurrencyTwo budgets, two names, on purpose: watch.concurrency counts issues in
flight (unchanged); watch.fanout.concurrency counts attempts of one
issue's fan-out in flight. They multiply rather than share one number — an
operator who wants "2 issues at a time, 1 attempt each" (serialize attempts,
keep issue-level parallelism) can say exactly that by setting
fanout.concurrency: 1, which a single shared knob couldn't express. piquette
doctor's watch.fanout line prints the product (concurrency × fanout.concurrency
chain runs in flight) so nobody has to compute it by hand.
Disk, not just chain runs. fanout.concurrency bounds attempts in
flight; it does not bound worktrees on disk. A successful attempt's
worktree is kept until every sibling in its fan-out has settled (it's still a
selection candidate), so peak disk per wave is watch.concurrency ×
watch.fanout.n full checkouts, not watch.concurrency × watch.fanout.concurrency
— at concurrency: 4, fanout.n: 8 that's 32 working copies at once under
worktreesDir (~/.piquette/watch/<repo>/worktrees). Plan disk against n, not
against fanout.concurrency.
n > 1 requires chain to have a commit step — piquette watch refuses to start
otherwise. The reason is sharper than piquette fanout's own version of this
check: best-of-N force-removes every losing attempt's worktree once a winner
is picked, --force included, and a chain that never commits leaves its
entire payload as uncommitted edits — fan-out would destroy N-1 candidates
outright rather than just fail one attempt. n: 1 has no such requirement (a
chain that commits nothing is handled the same honest way it always has been:
blocked, worktree cleaned, nothing discarded).
n: 1 (the default) changes nothing about a running daemon's observable
behaviour — same adw_id, same branch, same worktree, same fetch semantics,
same marker, same PR. piquette doctor still prints one informational
watch.fanout line either way, so the posture is visible even when best-of-N
is off.
GitHub (issue_provider: github and/or code_host: github)
export GITHUB_TOKEN=... # classic PAT; piquette doctor checks it's set
`