craft — clarify → plan → goal → workflow → human review
A structured loop for tasks worth doing properly: clarify with the user, draft a plan they
edit and approve, self-set a goal, run workflow.js — dispatched through farm-out — to implement
and independently verify, then put the result in front of the human in tuicr. Human rejection
routes back to CLARIFY.
┌────────── human REJECT / criteria wrong ──────────────────────────────┐
▼ │
CLARIFY ─► PLAN ─► GOAL ─► workflow.js ──PASS──► HUMAN REVIEW (tuicr) ──┤
(ask) (draft, (self- IMPLEMENT, then │ approved → done │
user send) VERIFY ∥ MECHANICAL │ findings → fix ─► re-run
edits) ∥ third-party (opt-in,│ workflow.js (subset)
advisory) → JS gate │
▲ │
└─fix┘ FAIL — re-run selector is tasksThatFlagged
+ mechanicalThatFailed + lensesThatFlagged
Plan review is computed and happens before dispatch, not inside it — plan-lint.ts over the
built args and plan-preflight.ts executing their commands at baseline, enforced by
craft-dispatch.sh while the run is still armed. No agent reads the plan markdown for defects
(see Plan review).
Everything lives in this skill directory — workflow.js, references/third-party.md,
scripts/craft-dispatch.sh (Phase 3+4 in one call), scripts/craft-pending.sh (is a dispatch
owed?), scripts/goal-self-send.sh, scripts/human-review-gate.sh, scripts/craft-result.sh.
Nothing here depends on any plugin.
The gate has a test suite; run it after touching workflow.js — node --check proves only that
the file parses. scripts/workflow-harness.mjs executes the script for real against stubbed hooks,
and scripts/workflow.test.ts asserts the invariants that decide PASS/FAIL: fail-closed on every
dead agent, the three redCommand verdicts, overallPass === false implying a non-empty selector,
the readOnly n/a dimensions. scripts/plan-review.test.ts guards the computed plan-review rules
and the absence of the judged layer. Add both to mechanicalChecks on a run that edits the spine:
bun test ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/ # absolute path: a bare relative path is
# read as a NAME FILTER and exits 1 having matched nothing — identical to a real failure
State
Two locations, one owner each:
| Path | Holds | Owner |
|---|---|---|
<plansDirectory>/<slug>.md |
the approved plan — the run's authority, the file that gets hashed | plan mode (native); craft only reads and hashes it |
.craft/<run-id>/ |
args, verdict JSON, and plan-<hash12>.md — the archived bytes each round ran under |
craft |
plansDirectory decides where that plan lives, and craft honours whatever it is set to —
"./.claude/plans" and "./.planning" (what the domain workflows use) are
equally valid. The value is resolved relative to the project root, so the plan is project-local and
craft hashes it in place — no copy. Unset at every tier, the default is .claude/plans. run-id is
a short date-slug like 0806-fix-auth. Both .craft/ and the plans directory want to be
gitignored — add them before the
first run if the repo would otherwise track them. Clean up .craft/<run-id>/ when the run
completes, unless the user wants provenance kept; leave the plan file alone either way, it's plan
mode's. The plan file is not durable and craft does not own it — plan mode memoizes one slug per
session, so re-entering plan mode overwrites the plan in place, and the directory is gitignored. So
dispatch archives the bytes it hashed to .craft/<run-id>/plan-<hash12>.md (content-addressed: an
amended round adds one, never overwrites). That archive is the only copy of what a run was approved
with — if the plan matters beyond the run, git add -f it before cleaning the run dir. There is no goal.md: the plan holds the success criteria, and the goal is a
session-level condition (Phase 3), not a file.
Phase 1 — CLARIFY
Before any task reconnaissance, AskUserQuestion on the axes that shape the plan (skip axes the request already answers; batch up to 4 per call):
- Desired outcome — what does done look like?
- Exclusions — what must NOT change?
- Constraints — style, deps, compatibility.
- Observable success criteria — what command/check proves it? A command with a meaningful
exit code becomes a
mechanicalChecksentry in the plan's Run sizing block. - Review surface — working tree (default), commit range, or PR?
- Third-party review? — none (default) / codex / gemini / both. This is the only opt-in moment; a plan approved without the opt-in line never runs third-party.
- Read-only runs only — use an agent team for discovery? (default yes.) Ask this axis only
when the run is an audit (
readOnly: true); for a run that writes, the answer is always no and the axis is skipped. The honest tradeoff: a team of communicating auditors catches cross-file defects that isolated lenses structurally cannot see — each lens judges alone and no lens holds two files at once. The cost is that a team's findings are correlated, so refutation must stay outside the team. Answering no is a real option; it costs discovery breadth, not gate integrity. For where the team runs and how its findings reach the gate, see Where the agent team lives.
Gate: you can plan without guessing. If answers surface a trivial task, say so and exit the loop — see red flags.
Phase 2 — PLAN
EnterPlanMode. Explore, then draft a plan that MUST contain:
Task table:
(id, name, work, writable paths, acceptance)— acceptance is a checkable criterion per task, not a vibe. AdddependsOnto any row whose refs or inputs are files another row writes: it is the only thing that orders IMPLEMENT, and rows without it are implemented concurrently (so their writable paths must be disjoint).Every claim is a
tasks[].acceptanceclause or amechanicalChecksentry, plus the review surface. A criterion belonging to no task belongs nowhere: it is a sentence nothing runs, and it is the largest defect surface a plan has. Whole-deliverable facts become a mechanicalCheck; per-task facts become that task's acceptance. Prose may explain WHY, never assert WHAT — the moment prose states a criterion there are two representations of one fact, and they drift within one amendment.plan-lint'sprose-commandrule enforces the executable half: a command in prose that no acceptance, redCommand or mechanicalCheck runs is a MAJOR.Run sizing — the scrutiny the gate will apply (Phase 4 documents what each knob costs):
## Run sizing Review lenses: criteria-vs-artifacts, scope-fidelity (+ one line per added lens, with the risk it covers) Mechanical checks: <name> — `<exact command>` (omit the section if none) Scored checks: <key> — <what it scores>, ADVISORY: never gates (opt-in, no default; omit if none) Test-first: <task id> — `<redCommand>` (one line per red-gated task; omit if none) Red dispositions: <task id> — <why this task carries no red gate> (one line per dispositioned task; omit if none) Third-party review: codex (only if opted in at CLARIFY)
Every task fans out to 1 implementer + 1 verifier, plus 2 probes if it carries a redCommand; every
lens to 1 reviewer + up to refutersPerLens refuters; every priorFindings entry costs one
refuter; every scoredChecks item costs one agent, advisory or not. If that runs past ~50, the
plan is too coarse-grained for one gate: split it into sequenced craft runs.
This is enforced, not advised. workflow.js computes its own fan-out floor
(2·tasks + 2·redGatedTasks + lenses + mechanicalChecks + scoredItems + priorFindings + thirdParty)
at arg-validation and throws before dispatching anything if it exceeds maxAgents (default 50);
the error prints the per-dimension breakdown. Raising maxAgents is legitimate; raising it silently
at dispatch time is what the throw prevents, because sizing is the user's call at approval time.
Sizing lives in the plan because it shapes the gate. Choosing lenses or dropping a mechanical
check after approval would weaken the verdict without changing a byte the user signed off on — the
hash covers the craft:dispatch spec block, so anything that decides PASS/FAIL has to be inside it.
An audit needs a plan too. readOnly still requires planPath + specHash, and the plan it
hashes is a charter, not a work order: what is being audited, which lenses judge it, which
mechanical checks run, and the standing instruction that nothing may be written. The task table may
be empty. Do not shortcut by hashing the artifact under audit instead: the AUTHORITY block tells
every agent the hashed file is its only authority, so hashing the audited file would tell each lens
that the thing it is judging is the standard it judges against.
Arm the run before calling ExitPlanMode. The plan must carry a dispatch block — every
workflow.js arg except planPath/specHash, which craft-dispatch.sh injects because a block
cannot state its own hash:
<!-- craft:dispatch
{"runId": "0813-slug", "goalTurns": 12, "args": { … }}
-->
Writing it is what arms the run, and the plan is the only file plan mode may write — which is also
the only thing that survives approval. Claude Code clears the context when a plan is approved near
the ceiling and re-seeds a bare Implement the following plan: session with no craft in it, which
will otherwise implement in the main thread. While a plan is armed and no .craft/*/args.json
records its hash, ~/.claude/hooks/main-thread-guard.sh denies Edit/Write/Agent in that project —
resolving the project from the nearest ancestor of cwd, so a cd cannot disarm it — and blocks the
turn from ending once; both name the dispatch command. It denies only what some task's
writablePaths covers (craft-dispatch.sh --covers, failing closed when the spec cannot decide):
a path no implementer may write is not the run's output, which is what leaves the red suite
authorable here and nowhere else. For the one case that rule cannot reach — a path that IS a
task's output and must still exist before wave 1 — the plan declares scaffoldPaths, and
craft-dispatch.sh --scaffold tells the guard to allow it. Without that list the only exit was
--abandon, which releases the guard for the whole rest of the session and silences the Stop
nudge along with it, so a plan defect became a disarmed run. A readOnly run never writes, so
there the Stop nudge is the only thing that fires. Derive the prose Run sizing block from the JSON;
craft-dispatch.sh prints the fan-out it computes, so drift shows up before anything is dispatched.
The user edits the plan file and approves via ExitPlanMode. Then hash the plan where plan mode actually wrote it — resolve the path, don't assume it:
# The configured location — plansDirectory, project-root-relative, default .claude/plans.
PLANS=$(jq -r '.plansDirectory // empty' .claude/settings.local.json .claude/settings.json \
~/.claude/settings.json 2>/dev/null | head -1); PLANS=${PLANS:-.claude/plans}
PLAN=$(ls -t "$PLANS"/*.md 2>/dev/null | head -1)
[ -n "$PLAN" ] || PLAN=$(ls -t ~/.claude/plans/*.md | head -1) # fallback: a pre-setting session
bash ~/.claude/skills/workflows/skills/craft/scripts/craft-dispatch.sh --spec-hash "$PLAN" # 64-hex spec hash
Whichever it resolves to is planPath. Never copy the plan — one file, hashed in place, is the
run's authority. A copy creates a second file that can drift from the one the user edits. (The run
dir's plan-<hash12>.md is not that: it is a dead snapshot named by its own hash, written by
dispatch and never read as authority, so nothing can edit it or drift from it.)
Ensure plansDirectory is set. Plans belong inside projectDir, alongside the work and the
agents. plansDirectory is relative to the project
root, so any project-relative value puts them there —
"./.claude/plans" and "./.planning" both work, and craft resolves whichever is set:
rg -n '"plansDirectory"' .claude/settings.local.json .claude/settings.json ~/.claude/settings.json 2>/dev/null
If no tier sets it, add one — "plansDirectory": "./.claude/plans" is this skill's default,
"./.planning" is what the domain workflows use — to the project's
.claude/settings.json, and gitignore that directory. Setting it at the user tier covers every
project at once, which is usually what you want. Precedence is Claude Code's own:
.claude/settings.local.json beats .claude/settings.json beats ~/.claude/settings.json.
It takes effect next session, not this one. Plan mode fixes the plan's path when you enter it, so
a session that started before the setting was live still writes to ~/.claude/plans/ — which is why
the snippet above resolves the real path instead of asserting one. Do not stop the run over it, and
do not copy the file to make the path look right.
The plan's craft:dispatch spec block is the sole authority every dispatched agent gets; the prose
around it explains but never binds. Nothing re-derives it: the agents re-run --spec-hash themselves
and stop on mismatch, so an amended spec halts the run instead of silently changing the contract,
while fixing a typo in the rationale costs nothing.
Phase 3 — GOAL
Self-send a /goal. No file — the plan holds the success criteria and it's what's hashed.
What /goal adds is mechanical: after each turn a separate evaluator model checks the condition,
and if it doesn't hold the session starts another turn instead of returning control to the user.
That is what runs craft's outer loop (gate FAIL → fix → re-run; tuicr findings → fix → re-review)
without the user prompting each step. workflow.js can't do this — it returns a verdict once.
craft-dispatch.sh does Phase 3 and Phase 4 in one call — it reads the armed plan's dispatch
block, injects planPath/specHash, writes args.json, self-sends the goal below, and starts
workflow.js detached. Run it and skip to the Monitor; the rest of these two phases is what it
does and why, and what to check when it reports something odd:
bash ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/craft-dispatch.sh # armed plan; or pass one
bash ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/craft-dispatch.sh --provider codex # "run craft through codex"
The provider comes from the invocation, and every skill that wraps craft forwards it. A provider
named in $ARGUMENTS, however it is spelled — --provider codex, --dispatch codex, "run this on
gpt" — is --provider codex on the dispatch line, whether craft was invoked directly or through /dev,
/ds, /writing, /notes, /slides or /exams. Those skills print their own dispatch recipe, so
each carries the flag too; dropping it silently runs the user's codex request on claude.
--provider is the ONE spelling: craft-dispatch.sh --dispatch and craft-redispatch.sh --dispatch codex each exit 2 naming it, rather than dying on "unknown flag" or accepting a synonym.
--provider claude|codex|gemini (default claude) runs the WHOLE spine on that provider — it
reaches farm.sh --provider, whose wrapper remaps the tier names, so every model: 'sonnet' in
workflow.js follows with no arg change. Same flag on craft-redispatch.sh, which is where it earns
its keep: when a round repeats its predecessor's failure exactly, a different provider is the lever
for framing lock-in (references/convergence.md). Whole-run granularity is structural — the provider
is chosen before workflow.js runs, so implementers and lenses cannot differ. It is deliberately not
written to args.json: differing between rounds is the point.
It needs nothing from the session's context, which is the point: a run whose context was cleared at plan approval is recovered by this one command, with no re-exploration.
The plan-review gates run before args.json is written, exiting 3 with the run still armed and
every artifact byte-identical. Tier 1 is plan-lint.ts on the built args. The two probe gates
execute disjoint command sets, each command exactly once: tier 2 runs every active task's
redCommand through the script's own classifier, refusing red-not-red (exit 0 — the gate already
passes) and could-not-run (exit 127, a missing runner, pytest exit 4/5, or no test output at
all), so only non-zero with a real test result proceeds; tier 2b runs every mechanicalChecks
cmd at baseline via plan-preflight.ts --only mechanical, where only a critical refuses.
Acceptance commands are plan-preflight's third probe kind and no dispatch gate runs them — run
--only acceptance by hand on a quiet tree, before arming.
--no-red-probe drops tier 2, --no-mech-probe drops tier 2b, --no-lint drops all of them, and
CRAFT_RED_PROBE_TIMEOUT / CRAFT_MECH_PROBE_TIMEOUT (300s each) bound their own tier's commands.
A task whose work is already COMPLETE can satisfy neither gate —
a redCommand is refused red-not-red, omitting it is refused redcommand-missing — so it declares
redDisposition instead; both scripts echo red: N gated, M dispositioned with each disposition,
beside the wave graph.
bash ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/goal-self-send.sh \
'/goal workflow.js has returned PASS for .claude/plans/<slug>.md at its current hash, and the tuicr gate has returned approved, or stop after N turns'
Name the plan by PATH, never by a fixed sha256. A pinned digest self-invalidates the first time the FAIL loop does what this file prescribes: fix, amend the plan, re-hash, re-dispatch. The run then PASSes against a hash the condition does not name, and the evaluator correctly reports the goal unmet on finished work. The path is stable; the hash is the thing the loop is expected to change.
Write the condition so the transcript can prove it. The evaluator judges only what has been surfaced in the conversation — it runs no commands and reads no files. "The acceptance criteria hold" is unjudgeable; "the gate returned PASS and tuicr returned approved" is judgeable, because both verdicts get printed. Include a turn clause to bound the run.
On a readOnly run the condition must not name PASS. Phrase it as "workflow.js has returned a
verdict for <planPath> and the tuicr gate has returned approved" — satisfied by either
verdict. A PASS-conditioned goal would keep starting turns driving toward an outcome the run is
forbidden to produce, because the only way to turn an audit's FAIL into a PASS is to fix what it
found, and a read-only run writes nothing.
Two mechanics worth knowing: setting a goal starts a turn immediately (that turn is Phase 4), and only one goal can be active per session.
Send it as the last action of the turn, then stop. The message lands in our own input queue
and cannot be processed until this turn ends — so there is nothing to wait for, and blocking on it
would deadlock. Do not dispatch Phase 4 in the same turn: the goal would be set after the gate
already ran. Let the turn end; the /goal turn is Phase 4.
Exit 0 means submitted, not processed. The script sends the paste and the Enter separately, then
watches the pane's input line: a swallowed Enter
leaves the text sitting there, so it retries — bounded at three presses over about five seconds,
then exit 5 saying the text is still in the box. It refuses outright (exit 6) if the box is non-empty
when it starts, the guard against pasting into a message the user is mid-way through typing.
Transports are tried in order: our own herdr pane, then agent-msg for Remote-Control sessions.
Refusals are fail-closed — every non-zero path exits before anything is sent, except the two
"send attempted and failed" cases (exit 5).
Whether the queued line is acted on is unknowable from here — that needs the turn to end. Exit
non-zero (no transport, or the Enter never took) is not fatal: the loop is written down here,
it just needs the user to prompt each step. Clear on final approval:
goal-self-send.sh '/goal clear'.
If any of this is in the way, print the /goal … line and let the user submit it. One keystroke,
no race, no ordering constraint.
Phase 4 — workflow.js
The args, annotated — write them to the args file as plain JSON, no comments, since the workflow
JSON.parses it:
{
projectDir, planPath: "<the path $PLAN resolved to in Phase 2>", specHash: "<64-hex>",
goal: "<one sentence>",
tasks: [{id, name, work, writablePaths, acceptance, refs, redCommand|redDisposition}, ...], // from the plan's task table
thirdParty: ["codex"], // ONLY if the plan carries the opt-in line; else omit
mechanicalChecks: [{name: "node-check", cmd: "node --check foo.js"}, ...], // optional
scoredChecks: [{key, items, prompt, schema, components, passthrough, refs, agentType}, ...], // optional; advisory, never gates
reviewLenses: [{key, prompt, refs, agentType}, ...], // optional; default is 2 lenses
// Standard, and the one to carry on any multi-task plan. `craft-dispatch.sh` PRINTS the wave shape
// `dependsOn` produces; this asks whether it has to be that shape. MAJOR-capped, so it never blocks:
// {key: "plan-parallelism", agentType: "Explore", refs: [],
// prompt: "Judge ONLY the approved plan's dependsOn edges. MAJOR at most, never CRITICAL — this is advisory. Given each task's `work`, `writablePaths` and `refs`: is any dependsOn edge unnecessary — could the two tasks run in the same wave? One finding per edge you would remove, naming the dependent, the dependency, and what the dependent actually needs from the dependency; if that is a BEHAVIOUR (an exit code, an observable effect) rather than a file the dependency writes, say so — an edge is a READ ordering, so a behaviour need is not one. Be specific or silent: a finding that names no concrete pair and no concrete reason is not a finding, and 'consider parallelising' is not one. Never argue for removing a test-first edge — the task that writes a failing test before the task that makes it pass — collapsing that means writing a test beside its fix, which is exactly what red-gating exists to prevent. Report nothing if every edge is load-bearing."},
authorityExtra: "<domain rule appended to every agent's AUTHORITY block>", // optional
implementerAgentType: "…", verifierAgentType: "Explore", // optional
readOnly: true, // optional; audit an existing tree
priorFindings: [{title, severity, detail, file, lens}, ...], // optional; discoveries made outside this run
freezeFindingSet: true, maxRounds: 3, // optional; set by craft-redispatch.sh, not by hand
maxAgents: 50, refutersPerLens: 8, // optional; fan-out ceilings — throws if the floor exceeds maxAgents
refuterModel: "sonnet", refuterEffort: "medium", // optional; null on either inherits the session default
}
Dispatch through farm-out, never the built-in Workflow tool — the guard at
~/.claude/hooks/main-thread-guard.sh denies it unconditionally, so an in-session call is
dead. farm.sh sets FARM_OUT_CHILD=1, which is what lets its child make the call.
craft-dispatch.sh runs exactly this and prints the wait loop with the run's paths filled in; what
follows is what it does, for when you are reading its output or a dispatch has gone wrong.
R=.craft/<run-id>; mkdir -p "$R" # the runner refuses if --out's directory does not exist
# write the args object above to "$R/args.json" as JSON
# DETACHED, never foreground: a real gate runs 20-60 min and a foreground tool call caps out
# and kills it mid-run, as does a harness-tracked background task.
setsid nohup bash ${CLAUDE_PLUGIN_ROOT}/skills/farm-out/scripts/farm.sh \
--workflow ${CLAUDE_PLUGIN_ROOT}/skills/craft/workflow.js \
--args "$PWD/$R/args.json" --out "$PWD/$R/result.json" --cwd "$PWD" \
> "$R/run.log" 2>&1 < /dev/null &
# Wait for it: craft-result.sh is one-shot and knows nothing about the dispatch. Watch BOTH the
# artifact and the process — a watcher that only greps for success is silent through a crash — and
# key liveness on THIS run's --out path, or a concurrent dispatch reads as proof ours is alive.
# farm-alive.sh keys on the RUN, not the runner's filename: the runner's event file carries
# out=<this run's --out> and its filename is the pid, so a rename cannot break the check.
while :; do
[ -s "$PWD/$R/result.json" ] && break
bash ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/farm-alive.sh "$PWD/$R/result.json" > /dev/null \
|| { echo "dispatch died with no verdict — see $R/run.log" >&2; exit 1; }
sleep 30
done
bash ${CLAUDE_PLUGIN_ROOT}/skills/craft/scripts/craft-result.sh "$PWD/$R/result.json"
--loops N executes that wait instead of printing it. After dispatch craft-dispatch.sh --loops N hands the run to craft-loop.sh, which polls with the liveness leg above, reads the verdict,
consults converge-check.ts every round, and redispatches to a cap of N rounds. N defaults to the
args maxRounds value, or 3 when absent; a non-numeric value is refused with exit 2 naming the flag.
--loops 0 is the printed path unchanged — the wait loop above prints and the script exits 0.
The driver's exit codes: 0 PASS; exit 1 the dispatch died with no verdict (see the run log it
names); exit 2 craft-result.sh refused the verdict; exit 5 converge-check.ts reported NOT
CONVERGING, which is evidence about the BRIEF — re-plan rather than spend the remaining rounds;
exit 6 the round cap was reached; exit 7 a plan defect needs a scope decision, which is a
human's to make.
Run that wait as a Monitor, not a foreground Bash call — Bash caps at 10 minutes and a gate
runs 20-60. Pass the loop body as Monitor's command with persistent: true (no deadline), keeping
both terminal states: result written, and process gone without one. Then call craft-result.sh when
it fires. Fall back to the loop across turns only where Monitor is unavailable — Bedrock, Vertex,
Foundry, or DISABLE_TELEMETRY/CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set. A Monitor dies with
the session; the detached run does not, so /goal remains the notifier that survives a restart.
farm.sh exits 2 on a malformed call (--workflow without --out, an --args file that is not
readable JSON) and non-zero when --out came back missing or not a JSON object. craft-result.sh
then refuses (exit 2) unless the file is one object carrying overallPass, verdict, scoreTable,
findings, tasksThatFlagged, mechanicalThatFailed and lensesThatFlagged with the right types,
and prints the verdict and the score table on success. All three selectors are required: a return
dropping one channel would make a FAIL carried solely by that channel read as a clean run.
State the residual plainly: mechanical claims are adjudicated, the rest is shape, not fidelity.
A model transcribes the workflow's returned object into --out, so a fabricated object with the
right keys — outside the re-run mechanical claims — passes both checks. Reconcile the score table
against the plan's Run sizing: counts that cannot be squared with what the run was sized to dispatch
make the result unverified, not a PASS.
| param | type | effect |
|---|---|---|
readOnly |
boolean (default false) |
Audit mode. No Implement phase and no per-task verifiers are dispatched, so tasks[] may be empty or absent (it is still required when readOnly is false). Lenses ∥ mechanical ∥ third-party run as usual, and every dispatched leg — lenses, refuters, mechanical probes and third-party runners — defaults to the Explore agent type, structurally no Edit/Write, unless a per-lens agentType says otherwise. Explore keeps Bash, so a probe can still run its command. Residuals, all from Bash, and this list is open rather than exhaustive: a mechanicalChecks cmd runs VERBATIM; any reference this spine tells a leg to follow can itself instruct a write; and authorityExtra and reviewLenses[].prompt are caller-supplied free text handed to every Bash-capable leg. What the agent type pins is the agent's volition — never what it is told to do. Anything a readOnly run hands a leg must itself be read-only. meta.phases is a static five-entry literal (Implement, Verify, Mechanical, Third-party, Gate) in both modes — the harness parses meta without running the script and rejects any computed value, so a mode-specific phase list is not expressible. Implement is therefore advertised and then never opened on a readOnly run; that is workflow.js's own progress display and is cosmetic. Craft's lifecycle Phase 1–5 (CLARIFY, PLAN, GOAL, workflow.js, HUMAN REVIEW) is a different axis and is unaffected. The task dimensions become n/a (null), not empty-and-clean: see the score-table note below. |
priorFindings |
[{title, severity, detail, file?, lens?}] |
Findings discovered outside this run — typically by a main-chat agent team. They are not trusted: each is refuted by the same adversarial path a lens finding takes (same schema, same default-to-refuted-when-ambiguous, same fail-closed rule that a dead refuter keeps the finding), and only survivors reach the gate, where they are gated identically to lens findings. An entry with no lens is attributed to the reserved key unattributed — the same key an unkeyed reviewLenses entry falls back to, since it is the same situation — so a survivor always names a lensesThatFlagged entry. Set lens explicitly to get a more specific label. file? is the path the finding is about — it is rendered into the refuter's prompt as [path] for context and is never opened by craft. It is not a location craft reads findings from; for where a team actually writes them, see Where the agent team lives. severity must be critical|major|minor; a malformed entry throws at arg-validation, before any agent is dispatched. An optional agentType on an entry overrides the refuter's agent type for that finding alone. Each entry costs one refuter agent, and the fan-out is bounded by nothing but the array you pass — so the count feeds the ~50-agent ceiling below. A minor entry cannot change the verdict (only critical|major reach survivingBlocking), so it spends a full agent to move a display counter: submit critical/major unless you specifically want the minor counted. scoreTable then carries priorFindingsSubmitted / priorFindingsSurviving. |
mechanicalChecks |
[{name: string, cmd: string}] |
Adds a Mechanical phase running in parallel with Verify. One low-effort probe agent per check runs cmd verbatim and reports {name, exitCode, output}; the JS reads the exit code — no agent asserts a pass. Fail closed: a dead or skipped probe is exitCode: -1, which counts as failed. Missing name or cmd throws. A probe's report is still a claim, and the claim is adjudicated in a shell: craft-result.sh re-runs EVERY declared check — a claimed failure included, since the file is a model's transcription of the gate object — and refuses (exit 2) when the observed exit code disagrees, or when a claimed non-zero exit sits beside overallPass: true. The refusal names which direction the disagreement went, because they mean opposite things: a claimed FAILURE that passes on re-run is a probe-side flake — re-run the gate, do not re-plan — while a claimed PASS that fails on re-run is the case the adjudicator exists for. Both still exit 2; passing a non-reproducing failure would wave a genuinely flaky gate through. Re-running one command to confirm a claim is cheap and re-running N is not — which is why a workflow declares one mechanical entry point whose exit code is its whole mechanical verdict, never a list of commands (a list also drops a check silently, and nothing reports a check it never knew about). A check's cmd must finish inside ~10 minutes, because it is run TWICE by two different callers that both cap there: a probe agent runs it through its Bash tool (hard ceiling 600s, not tunable) and craft-result.sh re-runs it to adjudicate the claim. A check that outlives that returns 124/137/143 — a kill, not an exit — which scored as a failing gate until craft-result.sh learned to refuse it. Anything genuinely long (a scale run, a soak) goes behind the gate, not inside it: run it detached (run_in_background, or a Monitor when you want per-event notice) writing an artifact, and let cmd be the fast read of that artifact. overallPass in result.json is NOT the verdict and must never be read directly, without exception — the verdict is craft-result.sh's exit code (0 pass, 1 fail, 2 refused), and any caller that reads the file another way is a defect. |
scoredChecks |
[{key, items, prompt, schema, components, refs, agentType?}] (default off) |
Weighted 0–10 scores, one agent per items entry, running in parallel with Verify. The agent returns RAW COUNTS and craft computes every score in JS from the caller-declared components, so no agent ever sees the formula it is scored by — an agent that reports its own score inflates it. It is advisory and structurally cannot gate: overallPass is computed without reading any scored value, there is no threshold and no blockBelow, it adds no selector channel, and even a dead agent does not flip the verdict. An unmeasured, dead or partially-reported item scores null with a reason — never base, never 0. Absent or [] opens no phase and dispatches nothing, and the return then carries scores: [] with scoresRun/scoresReported as null (n/a — render it as such, never 0). A schema key that is not a declared count, a score-shaped name, or a penalties key the schema does not declare all throw at arg-validation, before any dispatch. passthrough: [<field>, …] declares the evidence a score never reads — the numeric denominators a finding is stated against and the item lists it is built from — which a count-only whitelist cannot express; it stays a whitelist (an undeclared field is still refused, a field cannot be both a penalty and passthrough, and the score-shaped-name check applies to it too). Declared fields come back on that item's entry under evidence — nested, so nothing can collide with a component name; absent entirely when none was declared or reported; present on a null-scored item and never on a dead agent. Each item counts against maxAgents. Contract, arithmetic and worked example: references/scored-checks.md. |
tasks[].redCommand |
string (optional) |
Test-first gate that is executed, never asserted: a probe agent runs the string verbatim before the implementer — the JS requires a non-zero exit — and a second probe runs it after, where the JS requires zero. Three failure verdicts, each fails the task and puts its id in tasksThatFlagged: red-unproven (a probe died or was skipped, exitCode: -1), red-not-red (exit 0 before, so the test proves nothing), green-not-green (non-zero after). Must be one invocation — the shell operators ; & | ` $ > < ( ) { } and newlines throw at arg-validation, because the probe runs the string with its own authority and a shell program can fabricate RED; flags and quotes are fine, a multi-step check goes in a script you name. Costs **2 agents** against maxAgents; no probe is dispatched under readOnly. scoreTable then carries redGated, redProven, redUnproven, redNotRed, greenNotGreen, and the return carries red — feed it back as priorResults.red so a carried task keeps its adjudication instead of re-reading as unproven. It does **not** close everything: the command loads code the implementer may control, so keep writablePaths narrow. Absent leaves every existing caller byte-identical. |
tasks[].redDisposition |
string (optional) |
The filed reason a task carries no red gate — for work already complete, where any redCommand would be refused red-not-red. plan-lint accepts it INSTEAD of redCommand (both declared is red-both-declared, MAJOR; neither is redcommand-missing, MAJOR; empty/whitespace reads as absent), and dispatch echoes it verbatim. Its content is never validated — non-empty is the whole check; grading prose is the non-terminating shape. Inert to workflow.js: no probe, no agent, no score field. |
scaffoldPaths |
string[] (optional) |
Paths the plan authors before the dispatch, even though a task also writes them. Read only by main-thread-guard.sh via craft-dispatch.sh --scaffold (0 declared, 1 not, 2 undecidable → the guard fails closed); it changes nothing about how implementers run, and writablePaths still governs who may write what during the run. Exists for the greenfield red gate: a redCommand on a surface that does not exist yet fails to import, which is could-not-run, so a stub has to be on disk before wave 1 — and a stub is the implementer's output, so --covers alone can only deny it. Keep it to the specific file: a scaffold covering a task's whole writable surface is scaffold-swallows-task (major) at plan-lint. Absent leaves every existing caller byte-identical. |
tasks[].dependsOn |
string[] (task ids, optional) |
A read ordering: declare it when this task's refs, tests or inputs are files another task writes. IMPLEMENT then runs in waves — concurrent within a wave, waves in order. Absent everywhere leaves every existing caller byte-identical: one wave, tasks[] order. Refused at arg-validation, before any dispatch: a non-array or self-referencing value, an unknown id (a typo would silently drop the ordering it was written to enforce), a cycle (named with every id in it — a cycle means two tasks each need the other's output), and a wave whose tasks claim overlapping writablePaths (prefix-aware). An edge to a task outside onlyTasks is satisfied, not unschedulable — a prior run implemented it and its output is on disk, so refusing it would make every scoped re-run impossible. A redCommand still brackets its own implementer inside a wave, never a sibling's. |
tasks[].refs |
string[] (absolute paths) |
Files the implementer must Read in full before working. Absent or [] injects nothing into the prompt. |
reviewLenses[].refs |
string[] (absolute paths) |
The rules the lens judges against. The lens is told to read them IN FULL; its refuters are only told the paths, with an instruction not to open them unless the finding's own quoted evidence is insufficient (and to say so if they do). A lens is one agent doing open-ended reading; refuters are one agent per finding, so handing each the full ref set multiplies the run's largest read by the finding count. Absent or [] injects nothing either way. |
maxAgents |
number (default 50) |
Hard ceiling on the fan-out floor, checked at arg-validation. Throws before any agent is dispatched; the error names each dimension's count. Raise it deliberately, in the plan — see the sizing note above. |
refutersPerLens |
number (default 8) |
Cap on refuters dispatched per lens — the one fan-out term |
…(truncated)