craft — clarify → plan → goal → workflow → human review

SkillDev tools

Use when the user says \"work this\", \"run a work loop\", \"craft this\", \"do this properly\", \"take this through clarify plan and verify\", \"run it through the gate\", \"/work\", \"/craft\", or hands over a substantial change that has no domain workflow of its own and should be planned, approved and independently verified before it lands. NEGATIVE ROUTING: a code change or bug fix is /dev; a dataset, table, figure or number is /ds; long-form prose is /writing; a talk built from a research paper is /workshop; lecture notes or course slides are teaching:notes and teaching:slides; a skill, workflow or plugin in this repo is skill-creator, workflow-creator or plugin-creator. Each of those is this loop plus a domain gate, and work is only the fallback when none of them fits.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the craft — clarify → plan → goal → workflow → human review skill

What this skill tells your AI

The instructions your AI receives, as published by edwinhu/workflows in skills/work/SKILL.md and read by ahel’s review.

What this skill carries — grep references/ for any subject the names below miss: !d=${CLAUDE_SKILL_DIR}; command -v skill-toc >/dev/null 2>&1 && exec skill-toc "$d"; s=$HOME/.claude/skills/plugin-utils/bin/skill-toc; [ -x "$s" ] && exec "$s" "$d"; echo "(skill-toc unavailable: references and scripts are NOT listed here — install the plugin-utils plugin, or start a new session so its bin/ reaches PATH)"

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 itplan-lint.ts over the built args and plan-preflight.ts executing their commands at baseline, enforced by work-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/work-dispatch.sh (Phase 3+4 in one call), scripts/work-pending.sh (is a dispatch owed?), scripts/compose-goal.sh (the objective, and the ceiling the hold is armed with), scripts/human-review-gate.sh, scripts/work-result.sh. Nothing here depends on any plugin.

The gate has a test suite; run it after touching workflow.jsnode --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/work/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:

PathHoldsOwner
<plansDirectory>/<slug>.mdthe approved plan — the run's authority, the file that gets hashedplan mode (native); craft only reads and hashes it
.craft/<run-id>/args, verdict JSON, and plan-<hash12>.md — the archived bytes each round ran undercraft

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):

  1. Desired outcome — what does done look like?
  2. Exclusions — what must NOT change?
  3. Constraints — style, deps, compatibility.
  4. Observable success criteria — what command/check proves it? A command with a meaningful exit code becomes a mechanicalChecks entry in the plan's Run sizing block.
  5. Review surface — working tree (default), commit range, or PR?
  6. 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.
  7. 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. Add dependsOn to 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[].acceptance clause or a mechanicalChecks entry, 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's prose-command rule 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.

A domain workflow's plan opens with YAML frontmatter workflow: <name> (dev, ds, writing, workshop, or plugin-qualified like teaching:notes) — it is what the re-seeded Implement the following plan: prompt shows first, and what routes that session back to the owning skill.

Arm the run before calling ExitPlanMode. The plan must carry a dispatch block — every workflow.js arg except planPath/specHash, which work-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 (work-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 work-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; work-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/work/scripts/work-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 — HOLD + HEARTBEAT

Arm a hold, not a goal. No file holds the success criteria — the plan does, and it is what's hashed. What the hold adds is mechanical: hooks/until.ts RUNS a check on every Stop, and while it exits non-zero 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.

work-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, arms the hold, prints the CronCreate call that arms the heartbeat, and starts workflow.js detached. Run it, make the CronCreate call it prints, then 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/work/scripts/work-dispatch.sh                    # armed plan; or pass one
bash ${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/work-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: work-dispatch.sh --dispatch and work-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 work-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.

Phase 3 arms TWO halves, and work-dispatch.sh can only arm one of them

A DISPATCH THAT ENDS WITH A HOLD AND NO HEARTBEAT IS NOT DISPATCHED. It is the unattended idle this mechanism exists to prevent, and it is the likelier of the two failures because the half the script cannot arm is the half a model has to notice.

halfwhat it doeswho arms it
HOLDhooks/until.ts re-runs the check on every Stop and blocks the stop until it exits 0work-dispatch.sh, by writing a state file — no action needed from you
HEARTBEATa cron tick that starts a turn in a session that has already gone quietyou, with the CronCreate tool, in the dispatch turn

CronCreate is a model tool. There is no cron CLI, and a session-scoped cron lives in memory rather than in .claude/scheduled_tasks.json, so no shell can raise one — which is why this script used to self-send a /loop into its own pane instead. That transport is gone: it queued a line a detached drainer typed only when the pane went IDLE, and a session working back-to-back never goes idle. Measured 2026-09-16: /goal landed 127 times and missed 36, /loop landed 52 and missed 29.

So work-dispatch.sh prints the CronCreate call, with the exact cron expression and the exact prompt text, as the last thing on every path that dispatches. Make that call before your next action, and report the job id it returns. If CronCreate is unavailable, say so in one line — never proceed as though the heartbeat were armed.

Nothing is typed into this session and nothing is queued, so there is no send to verify, no transport to fall back to, and no ordering constraint against Phase 4.

The hold's check is work-result.sh, normalised to 0/1. Before the run returns there is no result.json and work-result.sh exits 2, which until-arm.sh correctly refuses as could-not-run rather than a verdict; the dispatch wraps it so the absent verdict reads as RED instead. A writing run's hold closes on PASS alone; a readOnly run's closes on either verdict, because an audit's gate legitimately FAILs and a PASS-conditioned hold would drive at an outcome the run is forbidden to produce. The two escapes the objective states as prose — the round counter and the wall clock — are also --rounds and --minutes on the armed hold, enforced by the hook rather than re-adjudicated.

bash ${CLAUDE_PLUGIN_ROOT}/skills/until/scripts/until-arm.sh --status settles whether the hold is live. CronList — the tool, not a shell — settles whether the heartbeat is. Check both.

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 objective so a COMMAND settles it. The hold runs the check; nothing reads the conversation to decide whether the run is done. "The acceptance criteria hold" is unrunnable; a work-result.sh exit code is the whole verdict. compose-goal.sh composes that text, and the dispatch reuses it verbatim as the heartbeat's tick prompt — the one string present when a tick fires into an otherwise empty session, which is why it carries the standing authority, the continuation rule and the CronDelete teardown as well as the check.

The hold self-clears: hooks/until.ts removes the state file the moment the check exits 0, so the normal ending needs no teardown. The heartbeat does not — a cron outlives the work and only CronDelete cancels it. That asymmetry is why the tick text ends with the teardown instruction.

Phase 4 dispatches in the same turn, immediately after the hold is armed and the CronCreate call is made. There is no queued message to wait out.

Phase 4 — workflow.js

The args, annotated — write them to the args file as plain JSON, no comments, since the workflow JSON.parses it:

This fence is craft's ARGUMENT SCHEMA, not a workflow declaring its own gate. mechanicalChecks: [{name, cmd}, ...] documents the parameter craft ACCEPTS; craft is the engine that RUNS a workflow's checks and has none of its own to collapse, so P10 — which governs what a generated workflow declares — is declared away for this region only:

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
21
Forks
4
Last commit
Sep 2026

ahel review

  • K6low
    bundled executables the agent is told to run
  • K3info
    injection (in scripts/work-goal-resend.test.ts)

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Catalog kind
skill
Gateway key
work-edwinhu
Source
github.com/edwinhu/workflows