This skill designs workflows. It does not carry its own lifecycle: the lifecycle is
work, and workflow-creator supplies the
domain — the CLARIFY axes, the lens, the mechanical checks, the authority text.
!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)"
The names and headings are the index; for a subject none of them carries,
grep -il <term> over the skill's own references/ directory.
work's mechanics live in work and are stated once. Everything below is a delta against
them: where a phase has no domain variation, this file says so and adds nothing. Restating them
here is how they drift — this file has already shipped a stale copy of work's Phase 3 once.
So work is not something you are asked to go read. It is inlined here, in full, before you see
anything else:
!cat ${CLAUDE_PLUGIN_ROOT}/skills/work/SKILL.md
That was work. Everything from here is the workflow-creator delta.
What a generated workflow is — read this before designing one. A skill that supplies
parameters to work's workflow.js. It does not ship a workflow.js of its own. The spine
already exists; a new workflow is a task table, one review lens, a set of mechanical checks, the
refs that carry its rules, and its authorityExtra — all passed to
${CLAUDE_PLUGIN_ROOT}/skills/work/workflow.js. That is the whole deliverable, and this skill
is itself the worked example: it is a SKILL.md and nothing more.
Emitting a .js per workflow is how one shared spine quietly becomes N spines, one delegation at a
time. Delegation is already structural — a Workflow script has no Write tool and no shell — so a
generated workflow inherits that guarantee for free by calling work rather than reimplementing
it, and needs almost no prose enforcement of its own.
IRON LAW — wanting a workflow.js means work is missing a parameter. Generalize work first.
The urge to write a domain .js is a signal, and it almost never points where it seems to. It says
the spine lacks a knob, not that this domain needs its own spine. So the first question is never
"how do I write this script?" — it is "which work parameter would make the script unnecessary?"
This skill is the worked precedent. Porting it hit four moments that each felt like "I need a script for this," and every one became a work parameter instead:
| the urge | the parameter it became |
|---|---|
| "I need to run deterministic checks and gate on them" | mechanicalChecks — the agent runs the command, the JS reads the exit code |
| "my implementers need the domain's rules in front of them" | tasks[].refs / lens.refs |
| "every agent needs to know one domain fact" | authorityExtra |
| "my reviewers must not be able to write" | implementerAgentType / verifierAgentType / per-lens agentType |
Four generalizations, zero new spines — and each one is now available to every other workflow,
which a private .js never would have been.
Only after that question has a genuine answer of "none": a domain .js is warranted when a
phase is a real fan-out with its own gate over its own index — expanding N outlines into prose,
rendering a spec into a deck — such that re-expressing it as task rows would lose structure.
Longer is not lost. Then the plan must record which work generalization was considered and why
it did not fit, and the script is referenced as {scriptPath: "<absolute path>"}, never by bare
name. In that case
references/gate-laws.md
governs the gate you are writing. Otherwise it still repays reading, because it describes the gate
you are inheriting — those laws are what work's own gate is built to satisfy.
Constraints, and the two ranks
A workflow is the set of constraints its output must satisfy, plus the machinery that decides
them. Constraints have two ranks, and mechanical is first-best: a command with an exit code —
deterministic, re-runnable, identical for every agent and every iteration. A lens is
second-best: a subagent's reading scored by work's JS, the fallback for a constraint that is
genuinely judgement.
The conversion duty. Every lens checklist item stands under one question — why is this not a command?
An item that flags the same shape twice is a lint rule nobody has written yet: write the rule, gate it
through the entry point, remove the item. The former path-resolution dimension is now P1/P2/P4/P6.
ONE mechanical entry point. All of a workflow's mechanical checks are reachable from a single
command whose exit code is the whole mechanical verdict —
scripts/check.sh here, one leg per check, none
short-circuiting. A list of N commands drops one silently and nothing reports a check it never knew
about; and work re-runs a claimed mechanical pass in a shell (work-result.sh), which is
affordable for one command and not for N. P10 refuses a second mechanicalChecks entry; a
genuine exception is declared with <!-- wc-probe: ignore-entry-point -->.
And it reaches its constraints by ONE name. A plugin shipping a constraint corpus exposes
scripts/load-constraints <scope>[,<scope>] [--index|--full] — index by default, because the prose
for one scope is 38,524 bytes against 1,386 and a load-time bang pays that on every invocation.
Calling the implementation file (load-constraints.py, load-constraints.ts) or an older name
couples every caller to a language choice; wc-probe's P13 refuses those. Cross-plugin, the corpus
is reached through bin/ on PATH (typst-rules), never through a hand-rolled $HOME resolver.
A generated workflow declares its own check entry point. check.sh gates workflow artifacts —
probe, parity, node-check, probe suite — and is not a generated workflow's domain gate. A workflow
whose domain has a toolchain (build, test, lint, render) ships one entry point of its own, collects
those legs behind it, and names that in its mechanicalChecks. The law binds the workflow being
generated, not only its generator.
THREE SHAPES, ONE DISCOVERY MECHANISM EACH. There is no fourth.
| the thing is | it is a | found by |
|---|---|---|
| a mechanical rule | SCRIPT in constraints/ |
the check runner, globbing that directory |
| knowledge | REFERENCE .md |
skill-toc, emitted by a bang at load |
| a procedure | SKILL | named in an agent's skills:, or invoked |
A rule no script can settle is usually the third, not a fourth: typst-visual-verify.md described a
render-score-fix loop with a >= 9.5 threshold that the visual-verify SKILL already implements,
down to the number, and typst-source-fidelity.md duplicated source-verify. Both were markdown
restating a procedure. Before writing a constraint doc for something a script cannot check, ask
whether it is a skill that already exists.
A constraint is enforced by a SCRIPT or by an AGENT WITH THE SKILL. There is no third shape. Its one canonical home is constraints/ at the plugin root; a copy per consumer is the vendoring defect, and a consumer in another plugin reaches it through bin/, never through a path.
ONE FRONTMATTER KEY: applies-to:. Nothing else. Scope is the only fact about a rule that no path already holds — the name is the filename, whether it is mechanised is whether checkers/<name>.py exists, and what it says is the body. Any other key is either a copy of one of those or metadata nothing reads, and both rot silently: measured 2026-09-14 in the typst corpus, type: sat in 17 of 21 files and testable: in 3, read by no code on any path, while description: had drifted from the rule beside it (a rule described as "table inset minimum 10pt" whose body leads with source-grounding) with no check able to see it. Lint the key set — a convention nothing computes is how type: got into 17 files and stayed.
-
Mechanical — rule
X.mdis checked bycheckers/X.py, by CONVENTION: no field declares the mapping, so nothing can name a script that moved. The workflow's check entry point runs it, and the set is DERIVED fromapplies-to:, never listed per consumer. -
Judgement — no checker exists at the rule's conventional name, and a grader agent judges it. Nothing declares that: the runner asks the filesystem, so a rule cannot claim to be mechanised while its checker is absent. Getting the corpus INTO that grader is the hard part, and two of the three obvious routes do not work. A
<bang>`cmd`in an agent definition is dead text. Andskills:frontmatter is documented to preload the skill's full content but did not on 2.1.257 — four probes, both name forms, underclaude -p --agentand the farm-out proxy, all NOT PRESENT (${CLAUDE_PLUGIN_ROOT}/references/bang-reach.md). Treat a preload as unproven until you have asked that agent, in your own dispatch path, whether the content is there. So a grader reaches the corpus by exactly one of:- invoking the skill, if it holds the
Skilltool — available, never automatic, so its prompt must say so; - reading the corpus itself with Read/Glob, which is all a read-only grader can do;
- the orchestrator inlining a computed index into the lens prompt, since the orchestrator is a skill and a bang works there.
Writing "the index in your context" into a grader that has neither the
Skilltool norrefstells it to grade against something it does not have. That shipped here for a day. - invoking the skill, if it holds the
Handing constraint prose to an agent as refs is neither, and it is the defect both shapes exist to remove. A rule a script can settle becomes two representations — the script and the module the agent reads — and the agent reads the one that goes stale. A rule a script cannot settle belongs to a grader that already has the corpus through its skill, so the refs copy adds nothing but a second path to keep current. refs remains right for what it was built for: the specific artefacts a task must read, which are not constraints.
A vendored <domain>-constraints skill is the same error made eagerly. It looks like determinism — every module guaranteed in the agent's context, no partial read possible — and pays for it with a second source of truth that nothing diffs. The copy is what agents actually read, so the day it drifts the canonical file is the one that is wrong in practice, and the workflow grades against prose its authors think they deleted.
-
A consumer that hand-lists which shared checkers it runs has the same defect later. Measured 2026-09-14: the list was the set of files in a directory, so deleting vendored copies deleted the SELECTION with them and every runner silently fell back to the whole corpus — lecture notes graded by slide rules. Derive the set from frontmatter; never write it down twice.
-
A grader that names its rules in prose drifts the same way. Measured 2026-09-14:
workshop-reviewersays "the fifteen canonical Typst constraint modules" where the corpus declares 21 forworkshop, so six rules had no grader and nothing could see it. An agent states the DOMAIN it grades, never the count or the list — and it cannot compute the list in its own file, so the skill it names is what has to carry the computed index. -
Measured 2026-09-01:
workshop-constraintsandds-constraintscarried byte-identical copies of 19 canonical modules — 15 typst, 4 DS — preloaded into four agents. No test in the repo could see them, because duplication across files is invisible to a per-file check.tests/constraints-no-duplication.test.tsnow fails on any file underskills/reproducing a canonical body; it found 17 offenders the day it was written.
Round shape
A round is shell checks, then ONE lens that reads a script-built digest at the top of its prompt and never re-runs a settled check. An exit code is read by the shell (work-checks.sh), never transcribed by an agent; P14 refuses the agent leg.
Rule compliance is a calibrated Jev rule in ruleChecks, gating the changed lines only. A rule is wired only after rule-calibrate passes it against real accepted material; a parked rule (uncalibrated/) never gates, and no lens or scoredChecks item re-grades a wired one (P15, P16). To build, calibrate or park one, invoke Skill(skill="workflows:jev-rules"); its new-rule.ts --wire is the only sanctioned move out of uncalibrated/.
A written rule that accepted practice breaks is the user's call, and the ruling is recorded in the rule. Never settle it by loosening the rule or by flagging the practice.
N independent items are N rows; fix a slow suite before fanning out rows that run it; mint every temp path per run (P17, P18).
| fact | backs |
|---|---|
523d42cd: a 4-task round fell from 16 agents to 5 once checks ran in the shell and one lens read the work-stage.mjs digest |
one lens, shell checks |
| 213223f6 unwired 9 ds rules that did not separate (A1 stayed); 8 came back only after two live runs at violating >= 0.93, compliant < 0.5 (e9c530ea) | calibrated rules only |
| 615d9baf: accepted decks breaking T-CALLOUT became legacy calibration bases, and the rule gates only changed lines | changed lines only |
EX-ORPHAN failed rule-calibrate and sat in teaching's uncalibrated/ (97eb24d), gating nothing, until a firmer extractor earned it --wire (c76dc78) |
parked rules never gate |
| be1722a2: L-SUPRA allows agency releases and bars bills per the user's 2026-10-02 rulings, written into its PROPOSITION | the user's call, recorded |
| A 9-rule recalibration handed to one row took 45+ min serially | one row per item |
f65da574: the full suite took 661-881 s serially and 63-67 s under bun test --parallel |
fast tests before fan-out |
2026-10-02: a fixed /tmp baseline-worktree name let one row's cleanup delete a sibling row's checkout |
a temp path per run |
Branch first
| The user wants | Branch | What runs |
|---|---|---|
| a workflow that does not exist yet | new | the full work loop (Phases 1-5) |
| an existing workflow fixed, redesigned, migrated, or given feedback | improve | the full work loop, seeded by a probe run as planning evidence |
| to know what is wrong with an existing workflow, changing nothing | audit-only | the same work loop with readOnly: true and tasks: [], seeded by a charter plan |
Audit-only is a readOnly work run, not a second lifecycle. work grew readOnly on
2026-08-07: under it no Implement phase is opened, no implementer and no per-task verifier is
dispatched, tasks[] may be empty, and every dispatched leg defaults to Explore — the lens
and the mechanical probes alike. Explore has no Edit and no Write, so no agent on a readOnly run
can write of its own volition.
The residual is Bash, which Explore keeps, and the exceptions are an OPEN list, not a closed
one: a mechanicalChecks cmd runs verbatim; any reference work tells a leg to follow can instruct
a write; authorityExtra and lens prompts are caller free text on every leg. The agent type pins
volition, never instruction — so everything a readOnly charter hands a leg must itself be
read-only.
Routing an audit through the spine keeps work's carried-finding adjudication, its
JS-computed gate, its dead-lens synthesis and its score table, none of which a hand-dispatched
Agent obtains. Per work's Phase 2 an audit still needs a plan, and that plan is a charter
rather than a work order: what is being audited, what the lens judges it against, which mechanical checks run,
and the standing instruction that nothing may be written. That makes an audit a plan-mode round trip; pay it.
const auditDir = /* absolute path of the workflow under audit */
const agentFile = /* absolute path of its agent .md, or null if it ships none — see Phase 4 */
const projectDir = /* the repo that contains auditDir — see Phase 4 */
// The args object. Written to the args file as plain JSON — no comments; the annotations are here.
const args = {
projectDir, planPath: "<the charter's path>", planHash: "<64-hex>",
goal: "<one sentence — phrase it as a verdict returned, never as PASS>",
readOnly: true,
tasks: [], // a charter carries no work order, and readOnly dispatches no implementer
// ONE entry: its exit code is the whole mechanical verdict. Same boundary as Phase 4 — the guard
// agent lives outside auditDir, so without `--agent` the audit excludes the file whose hook rules
// matter most.
mechanicalChecks: [
{ name: "wc-check",
cmd: `bash ${CLAUDE_PLUGIN_ROOT}/skills/workflow-creator/scripts/check.sh --target ${auditDir}${agentFile ? ` --agent ${agentFile}` : ''}` },
],
// The authoring judgement rules (constraints/jev/authoring), scored by Jev over the changed lines;
// each is wired only after rule-calibrate passed it.
ruleChecks: { name: "jev-authoring-rules", cmd: "bun ${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/rule-check.ts --project-dir <projectDir> --plan <planPath> --rules ${CLAUDE_PLUGIN_ROOT}/constraints/jev/authoring" },
// The Phase 4 lens verbatim, minus scope fidelity. Omitting `lens` selects work's generic default,
// not this domain checklist. Keep shared prompt items and refs in sync across both fences.
lens: {
agentType: "Explore",
refs: ["${CLAUDE_PLUGIN_ROOT}/skills/workflow-creator/references/gate-laws.md",
"${CLAUDE_PLUGIN_ROOT}/skills/work/SKILL.md"],
prompt: "Judge the workflow against the approved plan or audit charter and the goal. Read gate-laws.md in full before judging item (1). CHECKLIST, every item in scope on every run: (1) GATE INTEGRITY — judge the artifact's relationship to the gate, and ONLY the judgement residue: refs presence (P7), path resolution (P1/P2/P4/P6), the single entry point and lens parity (P10/P11) are decided by wc-probe's exit code, and an acceptance clause naming no command is decided by `work`'s plan-lint — do not re-litigate any of them. FIRST: does it ship a .js of its own? The default deliverable is a SKILL that passes parameters to `work`'s workflow.js — a domain .js is justified ONLY by a genuine fan-out with its own gate over its own index, and the plan must say so in writing. An unjustified .js is a CRITICAL finding: it forks the shared spine. If there is no .js, judge MEANING, which no exit code reads: is the declared lens decorative — is any clause of its ask answerable at all, and could a lint rule decide it instead, in which case that clause should have BEEN the rule; and does the one mechanical entry point actually exit non-zero when what it names is wrong, rather than passing vacuously over a set it matched nothing in. If a .js IS justified, judge it against the JS-gate laws in the refs: documented returns{...} keys match actual return{...} keys and selector ids live in the namespace the script filters against; the selective re-run path does not vacuously pass an empty set, does not disable verification, and carries forward as a union; overallPass===false implies a non-empty selector for EVERY fail path including whole-artifact ones owning no item; every gated dimension fails closed on a null agent result and distinguishes crash-drop from intentional skip; no gate field is produced by the agent whose work it certifies. (2) SPINE FIDELITY, against work/SKILL.md in refs — does the skill supply parameters to `work`'s spine rather than re-deriving one? Findings: a second lifecycle re-implemented inside the skill instead of parameterizing the spine; a domain .js that duplicates what `work`'s workflow.js already does; an orchestrator doing work a dispatched agent should do; a phase named in the plan that no task, lens item or mechanical check actually covers; a phase that runs but whose result reaches no gate; any of work's five phases silently dropped. CRITICAL where a dropped phase leaves a gated dimension certified by nothing. Severity across the checklist is MAJOR at minimum. MODE — RED (a task was flagged or a mechanical check failed): diagnose EVERY failure in the digest and route it, naming cause and fix, to the task id whose writablePaths own the file, or to 'plan' when no task can own the fix. MODE — GREEN (everything passed): make one open-ended pass over the whole checklist and report each finding with an ownerTask. In both modes, rule every carried finding open or closed against evidence you actually read; silence leaves it open.",
},
authorityExtra: "<the Phase 4 block, verbatim>",
// Optional: discoveries made outside this run — a main-chat agent team (CLARIFY axis 7),
// or a previous audit's surviving findings. They join the carried set the lens rules on.
priorFindings: [ /* {title, severity, detail, file} */ ],
}
Dispatch it the same way Phase 4 does; the built-in Workflow tool is denied by the guard at
~/.claude/hooks/main-thread-guard.sh:
# Dispatch EXACTLY as the work skill's Phase 4 states it — detached via `setsid nohup`, absolute
# --args/--out, then its wait loop, then work-result.sh. Do not copy the command here:
# this file already carried a foreground, relative-path copy that work's own rules
# contradict, and a generator's examples become everyone's dispatch.
The lens's SCOPE FIDELITY item is dropped on an audit, and that is a declared drop, not an
oversight. It judges whether changes stayed inside the plan's task table and writable paths; a
readOnly run makes no changes and its charter has no task table, so that item has nothing to judge
against and would spend the lens's attention reporting nothing. It is the only difference between
the two fences' lenses — refs and agentType are identical, and the rest of the prompt carries over
unchanged — so prompt is the one field declared in the grammar the probe parses, and P11 reads an
intended drop rather than a silent one. The declaration NAMES the lens FIELDS allowed to differ
(prompt, refs, agentType); it is not an exemption and has no whole-file off switch, so a
wc-probe: ignore-lens-set-parity marker is a P9 finding rather than a suppression — P11 polices
exactly one file per skill, and a marker that silenced it there would silence it everywhere.
Both verdicts go to Phase 5 via work's read-only findings-file path, because nothing was
written for -w to show. A FAIL is the audit's successful outcome, not a defect to fix. Do not fix.
If the user then wants the fixes, that is a new improve run with its own plan and its own gate.
Improve starts by running the probe against the existing workflow and reading the findings into the plan, so the task table is grounded in what the probe actually reported rather than in a guess about what is wrong.
Phase 1 — CLARIFY
AskUserQuestion on these axes before any reconnaissance (skip what the request already answers,
batch up to 4 per call). work has seven axes; these eight specialize the ones that take a
domain form, and the rest follow the list unspecialized:
- Workflow outcome — what does the generated workflow produce, and what does done look like for one run of it?
- Target repo — where does the generated workflow live? A personal skill under
~/dotfiles/.claude/skills/, a plugin repo, a project-local.claude/? This decides every path the artifact will contain. - The generated workflow's phases — expressed in
work's terms, sinceworkruns them: which task rows exist and in what order (the spine implements sequentially), what the one review lens judges the whole deliverable against, and which deterministic commands becomemechanicalChecks. If a phase does not map onto one of those three, that is the signal to test the fan-out exception — not to reach for a.jsby reflex. - Its gate — enumerate the constraints the generated workflow's output must satisfy and
classify each one mechanical or lens. Mechanical is first-best, so a lens rank needs its
answer to why is this not a command? written into the plan beside it; every mechanical
constraint is a leg of the one entry point, not a separate command. The arithmetic that
turns those into PASS/FAIL is
work's, computed in JS from raw counts. - Its re-run selector — what the fix loop feeds back on FAIL, and what id-namespace those ids
live in. Every path that can set
overallPassfalse needs a selector channel; a whole-artifact failure that owns no item needs its own. - Its human review surface — working tree, commit range, PR, or none.
- Mutation owners — which phase of the generated workflow is allowed to write which paths. Anything not named here is out of scope for it.
- Exclusions — what must not change in the target repo during this run.
Plus work's remaining axes, taken as work states them:
- Observable success criteria (
work4) — each becomes amechanicalChecksentry. - Lens provider (
work6) — routed by default; naming claude, codex or gemini passes it aslensProvider. - Agent team for discovery (
work7) — read-only runs only, default yes. This applies directly here, because audit-only is areadOnlywork run: ask the agent team axis on that branch and skip it on new and improve, where the run writes andwork's ban holds. A team of communicating auditors catches cross-file defects that isolated lenses structurally cannot see — a claim corrected in one file and left live in another is exactly the class this skill has shipped. Its findings come back correlated, so they enter aspriorFindings, join the carried set, and are ruled open or closed by the one lens outside the team — they cost no extra agent. - Constraints (
work3) — usually already answered by doctrine rather than asked: no emittedworkflow.js, the<generated-skill>/layout below, andreferences/gate-laws.md. Ask it only when the target repo imposes something those do not cover.
Gate: you can write the task table without guessing. If the answer is "add one line to an existing workflow," say the loop is overkill and just do it.
Phase 2 — PLAN
work's Phase 2 — including how the plan path is resolved and hashed. Two domain additions:
- The task table doubles as the generated workflow's output manifest. Every file the run will emit appears as some task's writable path, so nothing is created that the user did not approve, and nothing approved is silently skipped.
- A
refscolumn, required and may be empty — see Rules and references. Empty states that a task has no domain rules; absent is refused, because an omission cannot be told from a forgotten one.
| id | name | work | writable paths | refs | acceptance |
|---|
If the plan justifies a domain .js under the iron law above, it must also record which work
parameter was considered instead and why it did not fit.
Phase 3 — GOAL
work's Phase 3 unchanged — no domain variation. Follow it exactly.
Phase 4 — the work call
genDir below is the absolute path of the generated skill directory, decided at CLARIFY axis 2.
Everything else is work's.
const genDir = /* absolute path of the generated skill dir, from CLARIFY */
// The guard-bearing agent .md itself — under `.claude/agents` or `~/.claude/agents`, NOT a plugin
// root's `agents`. It is OUTSIDE genDir by construction (see the layout below), so the probe only
// judges it if you name it: `--target` is the probe's whole world, and `--agent` widens it by
// exactly one file WITHOUT dragging in the unrelated agents that share the discovery directory.
const agentFile = /* absolute path of the agent .md, or null if the workflow ships none */
// the work skill's `projectDir` is rendered into every dispatched agent's AUTHORITY block as
// "Work only there", so when CLARIFY axis 2 puts the generated skill in a DIFFERENT repo from the
// session, this is the tree that holds the artifact — not necessarily the session's cwd.
const projectDir = /* the repo that contains genDir */
// The args object. Written to the args file as plain JSON — no comments; the annotations are here.
const args = {
projectDir, planPath: "<the path $PLAN resolved to in Phase 2>", planHash: "<64-hex>",
goal: "<one sentence>",
tasks: [ /* the plan's table verbatim: {id, name, work, writablePaths, acceptance, refs} */ ],
// ONE entry, always: check.sh runs every leg (wc-probe, parity, node-check over genDir/*.js, and
// genDir's OWN probe suite) and its exit code is the whole mechanical verdict. A new deterministic
// check becomes a LEG of check.sh, never a second entry here — P10 refuses that.
// `--agent` whenever the workflow ships a guard agent: the file sits outside genDir and the probe
// walks genDir alone, so without it nothing scans the guard. Never a second `--target` at the
// discovery dir — that reads the agent without its skill, so ${CLAUDE_SKILL_DIR} resolves to
// nothing and the guard's path reports NOT CHECKED.
mechanicalChecks: [
{ name: "wc-check",
cmd: `bash ${CLAUDE_PLUGIN_ROOT}/skills/workflow-creator/scripts/check.sh --target ${genDir}${agentFile ? ` --agent ${agentFile}` : ''}` },
],
// The authoring judgement rules (constraints/jev/authoring), scored by Jev over the changed lines;
// each is wired only after rule-calibrate passed it.
ruleChecks: { name: "jev-authoring-rules", cmd: "bun ${CLAUDE_PLUGIN_ROOT}/skills/work/scripts/rule-check.ts --project-dir <projectDir> --plan <planPath> --rules ${CLAUDE_PLUGIN_ROOT}/constraints/jev/authoring" },
// One lens after verification and mechanical checks: gate integrity, spine fidelity and scope.
// Audit-only uses the same checklist minus scope, which has no changes to judge.
lens: {
agentType: "Explore",
refs: ["${CLAUDE_PLUGIN_ROOT}/skills/workflow-creator/references/gate-laws.md",
"${CLAUDE_PLUGIN_ROOT}/skills/work/SKILL.md"],
prompt: "Judge the workflow against the approved plan or audit charter and the goal. Read gate-laws.md in full before judging item (1). CHECKLIST, every item in scope on every run: (1) GATE INTEGRITY — judge the artifact's relationship to the gate, and ONLY the judgement residue: refs presence (P7), path resolution (P1/P2/P4/P6), the single entry point and lens parity (P10/P11) are decided by wc-probe's exit code, and an acceptance clause naming no command is decided by `work`'s plan-lint — do not re-litigate any of them. FIRST: does it ship a .js of its own? The default deliverable is a SKILL that passes parameters to `work`'s workflow.js — a domain .js is justified ONLY by a genuine fan-out with its own gate over its own index, and the plan must say so in writing. An unjustified .js is a CRITICAL finding: it forks the shared spine. If there is no .js, judge MEANING, which no exit code reads: is the declared lens decorative — is any clause of its ask answerable at all, and could a lint rule decide it instead, in which case that clause should have BEEN the rule; and does the one mechanical entry point actually exit non-zero when what it names is wrong, rather than passing vacuously over a set it matched nothing in. If a .js IS justified, judge it against the JS-gate laws in the refs: documented returns{...} keys match actual return{...} keys and selector ids live in the namespace the script filters against; the selective re-run path does not vacuously pass an empty set, does not disable verification, and carries forward as a union; overallPass===false implies a non-empty selector for EVERY fail path including whole-artifact ones owning no item; every gated dimension fails closed on a null agent result and distinguishes crash-drop from intentional skip; no gate field is produced by the agent whose work it certifies. (2) SPINE FIDELITY, against work/SKILL.md in refs — does the skill supply parameters to `work`'s spine rather than re-deriving one? Findings: a second lifecycle re-implemented inside the skill instead of parameterizing the spine; a domain .js that duplicates what `work`'s workflow.js already does; an orchestrator doing work a dispatched agent should do; a phase named in the plan that no task, lens item or mechanical check actually covers; a phase that runs but whose result reaches no gate; any of work's five phases silently dropped. CRITICAL where a dropped phase leaves a gated dimension certified by nothing. (3) SCOPE FIDELITY — did the changes stay inside the plan's task table and writable paths? Out-of-scope edits, unrequested features and silently skipped plan items are findings, CRITICAL where an edit landed outside every declared writable path. Severity across the checklist is MAJOR at minimum. MODE — RED (a task was flagged or a mechanical check failed): diagnose EVERY failure in the digest and route it, naming cause and fix, to the task id whose writablePaths own the file, or to 'plan' when no task can own the fix. MODE — GREEN (everything passed): make one open-ended pass over the whole checklist and report each finding with an ownerTask. In both modes, rule every carried finding open or closed against evidence you actually read; silence leaves it open.",
},
authorityExtra: [
"DOMAIN RULE — what you are building.",
"A workflow is a SKILL that supplies parameters to `work`'s workflow.js at ${CLAUDE_PLUGIN_ROOT}/skills/work/workflow.js. It does NOT ship a workflow.js of its own. The deliverable is a task table, one lens, mechanicalChecks, refs and authorityExtra — passed to the existing spine. The skill does not do the work and does not compute the verdict; `work` does both.",
"IRON LAW: if you find yourself wanting to write a workflow.js, `work` is missing a PARAMETER. Ask which work parameter would make the script unnecessary, and propose generalizing work instead — that is how mechanicalChecks, refs, authorityExtra and the agentType overrides all came to exist. A parameter serves every future workflow; a private .js serves one and forks the spine. Only after that question genuinely answers 'none' — a true fan-out with its own gate over its own index — may a .js be written, and the plan must record which generalization was considered and why it did not fit.",
"A domain workflow is referenced by {scriptPath: '<absolute path>'}, NEVER by bare name: a bare name resolves only through .claude/workflows/, so a script shipped alongside a skill is unreachable by its own meta.name.",
"Enforcement in the generated artifact is structural where structure can carry it — a Workflow script has no Write tool — and a hook only where structure cannot.",
].join("\n"),
verifierAgentType: "Explore",
lensProvider: "codex", // ONLY if CLARIFY axis 6 named a provider; else omit (routed)
}
Dispatch through farm-out, never the built-in Workflow tool — the guard at
~/.claude/hooks/main-thread-guard.sh denies Workflow unconditionally, so an in-session call
is dead on arrival. farm.sh sets FARM_OUT_CHILD=1, which is what lets its child make the call:
# Dispatch EXACTLY as the work skill's Phase 4 states it — detached via `setsid nohup`, absolute
# --args/--out, then its wait loop, then work-result.sh. Do not copy the command here:
# a foreground or relative-path copy contradicts `work`'s own rules and dies mid-run.
farm.sh exits 2 on a malformed call and non-zero when --out came back missing or not a JSON
object; work-result.sh then refuses (exit 2) unless that object carries work's seven required
return keys — all three selectors included — at the right types, and re-runs every
mechanical check the result claims, refusing when the shell's exit code disagrees with the claimed
one. It adjudicates rather than validating shape: its exit code is the verdict — 0 pass, 1 fail,
2 refused — and overallPass is never read directly. Adjudication reaches the mechanical claims
only; a model still transcribes the rest, so reconcile scoreTable against the plan's Run sizing
before believing a PASS.
verifierAgentType and the per-lens agentType pin Explore because it has no Edit and no Write:
a judge that structurally cannot modify the tree beats a prompt that asks it not to.
implementerAgentType is deliberately unset for this workflow's own run, which is not a
contradiction of the guidance above that a generated workflow should usually set one. The output
here is workflow definitions — SKILL.md dispatch blocks, hooks, scripts — so the implementer is
writing software, and Claude Code's software-engineering system prompt is the correct framing rather
than the defect it is where the output is prose. A custom agent is justified only by a custom
prompt, hooks or preloaded skills; none of the three applies, so creating one would be an agent
justified by nothing.
On the result, follow work's Phase 4 handling — and remember the re-run selector is all four of
tasksThatFlagged, mechanicalThatFailed, lensesThatFlagged and planFindings. A wc-probe
failure owns no task by itself; the lens routes it to the task whose writablePaths own the offending
file, and you re-invoke with the same mechanicalChecks, which always re-run. A blocking lens finding
the lens could route to no task lands in planFindings, and the remedy there is to amend the plan,
re-hash and re-dispatch — work-redispatch.sh refuses a re-run whose planFindings are non-empty
while the spec hash is unchanged. lensesThatFlagged is ["lens"] whenever such a finding still
stands and [] otherwise, so it says THAT the review blocked, never which of the checklist's three
items did; findings[] and routes[] say that. mechanicalThatFailed names wc-check, whose own leg
lines say which leg failed.
scoreTable.mechanicalRun: 0 means the phase was skipped, not that mechanics are clean.
Phase 5 — HUMAN REVIEW
work's Phase 5 applies, including the verdict handling and the two-rejection cap. It is no longer a
single path: work grew a read-only branch, so there is one domain note per branch.
- new / improve — the review surface is the generated skill directory, so the human is reading the workflow that was built, not output it produced. Nobody has run it yet.
- audit-only —
work's read-only path governs.-wover a tree nothing wrote to opens an empty diff and returnsunreviewed, whichworkcalls not approval, so the orchestrator first writes.work/<run-id>/findings.mdfrom the objectworkflow.jsreturned and reviews that file with--file. Transcribe, do not grade: counts fromscoreTable, findings fromfindings[], verdict fromverdict— the agent that ran the audit must not also summarise it in its own voice. The domain note is that the review surface is a diagnosis of a workflow nobody changed, sofindingsfrom the human are input to a later improve run, not edits to make now..work/is gitignored, so an audit worth keeping has to be moved somewhere tracked — say so to the user.
Rules and references
Four audiences need a generated workflow's REFERENCE DOCUMENTS, and each has its own delivery
point. Constraints are not on this table — they reach an agent through the index skill it names in
its own skills: frontmatter, never through refs. That preload is UNPROVEN on 2.1.257 (see the
Judgement bullet above and ${CLAUDE_PLUGIN_ROOT}/references/bang-reach.md); an agent with no Skill tool has no
fallback when it does not land, so probe your own dispatch path before relying on it:
| audience | delivery |
|---|---|
| the orchestrator (the generated skill) | a bang at load — glob its references/, or grep a corpus by scope |
| implementer agents | tasks[].refs |
| the judging agent — the one review lens | lens.refs |
| everyone, for a short rule | authorityExtra |
<bang> in this file means a literal ! followed by a backtick. Written out, it would RUN:
the parser fires on that sequence at line start or after whitespace, and a fenced code block does
not protect it — nor does inline code. Both creator skills were taken offline on 2026-09-15 by
their own examples, one of which executed a placeholder named cmd.
The plugin root a generated workflow writes into
skill-creator's Where a thing goes has the full table and which names the harness owns. The
four that decide where a workflow's own files land:
constraints/ |
its rules, as scripts — one directory per plugin, no second copy |
references/ |
knowledge read by 2+ of its skills; one-consumer knowledge goes in <skill>/references/ |
scripts/ |
the workflow's own tooling, check.sh among it — not reachable from another plugin |
bin/ |
anything another plugin must call, because that is the ONLY cross-plugin reach |
A workflow that needs a rule from elsewhere calls that plugin's bin/ entry point. It does not
reach across a path, and it does not copy the rule in.
Two kinds of markdown: CONSTRAINTS and REFERENCES
Every .md a workflow carries is one or the other, and which one decides everything downstream.
| CONSTRAINT | REFERENCE | |
|---|---|---|
| is | a rule the work is GRADED against | knowledge the work draws on |
| answers | "is this deck allowed to do that" | "how does this API behave" |
| can be | violated — and a script may compute the violation | only wrong, and then you fix the file |
| declares | applies-to: and nothing else |
no frontmatter; a # Heading labels it |
| is found by | grepping the field — a consumer needs its own subset | a TOC — every ## heading, because you want all of it |
| reaches an agent by | the index skill named in that agent's skills: |
refs, or a Read at the phase that needs it |
The frontmatter is how a constraint DECLARES itself, not what makes it one — and the declaration
tracks the type exactly: measured 2026-09-14, 219 files across 37 skills split 200/19, the 19
carrying frontmatter being precisely the 19 without a # Heading first line.
Location follows from the type for a constraint, and from consumers for a reference.
A CONSTRAINT is a script, so it lives in constraints/ at the plugin root of whoever owns the
rule — one directory per plugin, never a second copy, and reached from another plugin through a
bin/ entry point rather than a path (typst-constraints --dir). There is no such thing as a
constraint that lives somewhere else: a rule no script can settle is not a constraint at all, it is
a REFERENCE (knowledge a grader reads) or a SKILL (a procedure), and goes where those go.
A REFERENCE goes where its consumers are. One domain → beside its skill, in <skill>/references/.
Several → the plugin root's references/, because the alternative is a copy per consumer, which is
the vendoring defect. Measured 2026-09-14: 5 of the 10 files at this plugin's root are read by two
or more skills and belong there; the other 5 have exactly one consumer and belong one level down.
Measure before moving anything, and beware the half-migrated state: typst carries BOTH
constraints/ (29 scripts, correct) and constraints/ (22 markdown, 14 of which merely
restate a script sitting beside them). That duplicate is the debt this rule exists to retire, not a
second sanctioned location.
BOTH are a bang; the bang is the only thing that computes at load. Grep and glob are not alternatives to it, they are what it RUNS — grep the field when a subset is meaningful, glob the directory when it is not. Never a hand-written list either way: one falls behind the moment someone adds a file and nothing shows it, which is why 16 reference files across 10 skills are named by no SKILL.md today.
A bang reaches an orchestrator skill and nothing else — see the delivery table above for the other
three audiences. refs names one artefact; a bang discovers a set.
Emit a real table of contents — every ## heading, not just the filename. A filename routes
badly and a heading routes well: 13f-scrape-performance.md does not tell an agent the file
answers "filings declared windows-1252 parsed to zero rows", and its heading does. Measured against
the content it stands in for, the full TOC costs 1.9–2.5% (wrds 10 KB over 516 KB, ~2,500 tokens;
writing 3 KB, ~740) where filenames alone cost 0.3–0.8%. Four times the size, and the difference is
the whole routing value — the agent then reads the two files it needs instead of three speculative
ones at 5,000 tokens each.
Pair it with the instruction grep cannot replace at load time, because at load there is no query yet — a plain document has no field to filter on, so the TOC is all a bang can offer and content search belongs at USE time:
The names are the index. For a subject no name carries, grep the bodies:
grep -il <term> <the references dir>/*.md.
A skill lists its own references/ AND its own scripts/ at load with one line:
<bang>`skill-toc ${CLAUDE_SKILL_DIR}`
skill-toc ships in plugin-utils/bin/, and Claude Code puts every enabled plugin's bin/ on the
Bash tool's PATH, so that bare command resolves from any plugin's SKILL.md. See skill-creator,
The two TOCs, for what it renders and why it is a script. Do not hand-write either list.
THREE THINGS WILL BITE, all measured; ${CLAUDE_PLUGIN_ROOT}/references/bang-reach.md has the evidence. A bang runs
only in an INVOKED file — a SKILL.md or a slash command — and is dead text in an agent definition
or a CLAUDE.md, so a grader never gets a computed set from a bang of its own — it names the skill
in skills: and is TOLD to invoke it, or the orchestrator inlines the index into its prompt. NO BACKTICK may appear inside the command, escaped or not: the parser truncates the
span at the first one and bash dies on the fragment, aborting the whole load. And an empty result
must be made to EXIT 2 — a search exiting 1 is tolerated, the bang renders nothing, and the reader
takes an empty list for a clean scope.
refs is how a reference DOCUMENT reaches the agent that needs it instead of stopping at the
orchestrator or being paraphrased into a prompt. Constraints never travel that way — a grader gets
them from the index skill it names, so a refs copy is a second path to keep current.
Required declaration: every task row and every lens in a plan this skill approves declares
refs. An empty list is allowed — it states the task has no domain rules. An absent key is
refused, because an omission is indistinguishable from a forgotten one. wc-probe.ts P7 refuses a
task row or lens with no refs key and checks that every declared ref resolves on disk, so a rule
reference is checkable rather than hoped-for.
A fence is code because of what it holds, not how it is labelled. The deliverable is a SKILL.md,
so the work args object, its task rows and its lens live inside a fenced block. P6/P7 read the
interior of any fence labelled js/ts or containing a Workflow( call — whatever its info
string. The rule used to be "label it js, or the gate cannot see it", enforced by a P8 fence labelling; that rule was authored against a corpus that does not follow it. Of the real Workflow(
call sites in the spine skills, most sit in a bare fence and several in a ```text one,
so a label rule left the gate reading a small minority of the call sites it exists to judge and
flagged the majority as defective for rendering the way everything else does. P8 is gone; label
the fence however you like — but an args object dispatched through farm.sh carries no Workflow(
call to be recognised by content, so label that fence js or P7 never checks its refs.
This strictness is workflow-creator's, not work's: work treats refs as optional so its existing
callers keep working. workflow-creator is strict about what it emits.
Emitted hooks — where a write-time guard must attach to fire
Structure covers implementation-time enforcement (no Write tool in a Workflow script). Two gaps
remain: a write-time domain constraint inside a dispatched implementer, which mechanicalChecks
would only catch after the fact at gate time; and the conversational phases (CLARIFY, PLAN),
where the orchestrator is a free agent in main chat.
Where such a hook must live was measured, not reasoned. The experiment, its apparatus, and the
verbatim hook payloads are in
references/hook-reach.md.
Observed (Claude Code 2.1.224, Linux, PostToolUse on Write|Edit, fresh headless sessions):
| hook declared in | write performed by | log entry? |
|---|---|---|
| skill frontmatter | the main session itself (control) | yes |
| skill frontmatter | a direct Agent subagent |
none |
| skill frontmatter | a Workflow-dispatched agent |
none |
| agent frontmatter | a direct Agent subagent |
yes |
| agent frontmatter | a Workflow-dispatched agent |
yes |
The control matters: it shows the skill hook was registered and firing, so the two "none" rows are a reach limit, not a broken apparatus.
This skill's own validate-on-write hook was confirmed the same way — the transcript
excerpt and exactly what it does and does not establish are in
references/hook-reach.md. It shows the
hook fired and what the hook process printed, and NOT what the writing model received.
Unmeasured, and therefore treated as unreliable: whether an ADVISORY systemMessage reaches the
writing model. No cell in references/hook-reach.md records what the writing agent received from a
hook that fired and exited 0. (Cell A's note that the subagent saw no PostToolUse output is about a
skill hook that never fired at all, so it says nothing about delivery from a hook that did.) Design
as if an advisory systemMessage will not change an agent's behavior: put anything that must change
it in a mechanicalCheck (wc-probe.ts, whose exit code gates the run) or in a blocking
PreToolUse guard — the blocking path is measured to arrive verbatim, the advisory one is not.
Ask for a tools: allowlist FIRST; a hook only when the restriction cannot be expressed as one.
An allowlist prevents the write; a hook reacts to one, and only its blocking form is known to
reach the writing model (below). A hook earns its place only for restrictions an allowlist cannot state
— "may write, but only under these paths", "must run the formatter after".
When it IS a hook, the guard rides on the agent, in that agent's own frontmatter, threaded into
the workflow via implementerAgentType — that is a large part of why the override earns its place. That agent file must sit in an agent-discovery
location that also DELIVERS hooks (.claude/agents/ or ~/.claude/agents/), not under the
generated skill's own directory, where nothing registers it. Two separate things are measured here:
the hook fires on a dispatched agent's write (cells C and D), and a blocking guard —
exit 2 with the reason on stderr — reaches the implementer verbatim (2026-08-08). What is still
unmeasured is ADVISORY output: anything the hook prints while exiting 0. So make the guard's effect
structural — a blocking decision, or a finding a mechanicalCheck re-derives at gate time — rather
than assuming the implementer reads what a non-blocking hook prints. By contrast, a guard declared in the generated SKILL.md's frontmatter was observed firing
only on the main session's own writes, so scope it to the conversational phases and do not expect it
to see a dispatched agent's writes.
Token substitution in hooks: frontmatter, also observed. CLAUDE_SKILL_DIR does not
substitute there: it reached the hook process as an empty argument and was unset in the hook's
environment, so a command written against it ran with a broken path and silently found no script.
The absolute command path executed on every write that reached the hook at all.
CLAUDE_PLUGIN_ROOT was set in the hook environment of a SKILL's hook — for a personal skill
it pointed at the installed ~/.claude/skills/<name> directory — and a command written against its
brace form did execute. Prefer an absolute path anyway.
In a BODY the token substitutes only when the file is plugin-shipped. Read out of 2.1.226: the
plugin skill/command loader pushes the body through the same CLAUDE_PLUGIN_ROOT replacement it
applies to allowed-tools, and the plugin agent loader does it to the agent body — while the
non-plugin loader replaces CLAUDE_SKILL_DIR/CLAUDE_PROJECT_DIR/CLAUDE_SESSION_ID and never
this one. So P4 is a non-plugin rule; firing it on a plugin-shipped body is a finding against
correct code.
In an AGENT's hooks: the token is refused (P1). The docs define it as the plugin installation
directory, so an agent outside a plugin has none — and a plugin-shipped agent has its hooks: block
ignored entirely. Nothing the harness supplies can make it resolve there. Resolving it anyway is
what let one unchanged agent read CLEAN under one probe target and CRITICAL under another.
Measured 2026-08-08 on 2.1.226 (references/hook-reach.md): PreToolUse and PostToolUse both
fire under claude -p, from settings.local.json and from a ~/.claude/agents/ agent's own
hooks:, for Bash as well as Write; settings hooks fire inside subagents; and a blocking
guard (exit 2 + stderr) stops the write and reaches the writing model verbatim. An earlier claim
here that headless suppressed these was a bad matcher, not a harness behaviour.
Not measured, so do not claim it: whether a skill hook reaches a subagent that invokes the skill
itself; long-lived interactive sessions (every cell ran in a fresh headless session); and whether
advisory output — a systemMessage, or anything printed while exiting 0 — reaches the model
that performed the write. Only the blocking form was shown to.
The layout that follows from the observations:
<generated-skill>/
├── SKILL.md the whole workflow: `work`'s phases + the params it passes.
│ Sets implementerAgentType: 'x-impl'. hooks: here only for
│ the conversational phases — they do NOT reach dispatched agents
├── references/*.md the rules, delivered to agents by refs
└── scripts/*.ts the guard body and any probes
.claude/agents/x-impl.md NOT under the skill directory. name: x-impl, and
hooks: PostToolUse Write|Edit -> the write-time domain guard
The agent file does not live inside the skill. Agent discovery has four roots. Three are
documented as scanned RECURSIVELY — a definition in a subfolder registers, so for those depth is
never the test; being under a root is. Line cites below are into the .md view of
code.claude.com/docs/en/sub-agents.
.claude/agents/(project,:163). Discovery walks UP from the cwd, so EVERY.claude/agents/between cwd and the repository root is scanned, and on a duplicatenamethe definition closest to the cwd wins (:169). A.claude/agents/inside an--add-dirdirectory loads alongside these (:171).~/.claude/agents/(personal,:164).- A plugin's own
agents/(:165) — not just its top level. Here, and only here, a subfolder becomes part of the scoped identifier:<plugin>/agents/review/security.mdin pluginmy-pluginregisters asmy-plugin:review:security(:179). In the project and personal roots the subfolder path does not affect identity, which comes only fromname(:175). .claude/agents/inside the managed-settings directory; a managed definition takes precedence over a project or personal one of the same name (:223). The cited line does NOT say this root is scanned recursively — the three above are, this one is unstated. Do not assume a subfolder registers here.
A file at <skill>/agents/x-impl.md registers no agent, because a skill directory is none of those
roots — not even inside a plugin, where the root is <plugin>/agents/. The shipped
skills/skill-creator/agents/*.md files in the plugin cache also carry no name: frontmatter and
are prose the skill reads, not agents the harness loads. Put the guard-bearing agent under a
discovery root, give it a name: matching the implementerAgentType string, and confirm it
resolves before relying on it — an implementerAgentType naming an unregistered agent means the
guard was never installed.
Registration and hook DELIVERY are different questions. hooks:, mcpServers: and
permissionMode: are IGNORED for a plugin-shipped agent — it registers and dispatches normally,
so nothing looks broken, but the guard never fires. Use .claude/agents/ or ~/.claude/agents/ for
a guard; inside a plugin, put it in that plugin's hooks/hooks.json. wc-probe --agent enforces
this.
Moving it out of the skill moves it out of the gate, so name it with --agent. wc-probe walks
--target and nothing else, and the coverage line cannot reveal the gap — it prints a full N of N
over a tree that never held the file. --agent judges the guard against the skill it guards and
widens the scan by exactly that file, so unrelated agents in .claude/agents/ stay out of the gate.
A second --target at the discovery directory does not substitute: with no skill in scope
${CLAUDE_SKILL_DIR} resolves to nothing and the guard's path reports NOT CHECKED.
No workflow.js — the spine is work's, called by scriptPath. Add one only under the fan-out
exception, with the justification written into the plan.
A script that gates a run needs its own suite, and that suite is a leg of the entry point. A probe
under scripts/ decides PASS/FAIL for every future run of the workflow, so its own correctness is
load-bearing in a way no lens covers: a predicate that silently matches nothing reports CLEAN, which
is the vacuous-pass class references/gate-laws.md L2(a) forbids. Ship scripts/<probe>.test.ts
beside it; check.sh's probe-tests leg is target-relative, so it runs THAT skill's suite. This
skill's own is
scripts/wc-probe.test.ts:
bun test ${CLAUDE_PLUGIN_ROOT}/skills/workflow-creator/scripts/wc-probe.test.ts
The path is spelled out because ~/dotfiles carries no package.json or bunfig.toml, so a bare
bun test from the repo root matches nothing and exits non-zero.
Run every new test against the OLD probe first; it must FAIL. A test written beside its fix passes either because the fix works or because it never exercised the defect, and the green looks identical. The failing run is the only thing that distinguishes them.
cd <repo>/.claude/skills/workflow-creator/scripts
cp wc-probe.ts /tmp/fixed.ts && git show HEAD:.claude/skills/workflow-creator/scripts/wc-probe.ts > wc-probe.ts
bun test ./wc-probe.test.ts -t '<new test name>' # READ THE COUNTS, not the exit code
cp /tmp/fixed.ts wc-probe.ts
Read N fail, never the exit status. bun test -t exits 1 when the filter matches nothing,
which is byte-identical to the failure this step is looking for — so a typo'd name, or a title
-t cannot match, reads as proof the test discriminates. Measured: a real all-passing block exits
0, an unmatched filter exits 1. Titles are regexes, so a name containing ${…} matches nothing ($
then an invalid quantifier) and a run filtered on it is a vacuous pass. Confirm the reported fail
count is the number of tests you added.
And the deeper limit, paid for twice: a failing run proves the test DISCRIMINATES, not that it discriminates toward the correct answer. One test here was proved to fail against the old code while pinning a hole the official documentation contradicted. Where a test encodes a claim about the harness, check the claim against the docs or the binary — the delta cannot tell you that.
Two corollaries: run any mechanicalCheck you write by hand before committing it — a cmd
inside a SKILL.md is a literal the probe never executes; and point the probe at a real shipped
skill, not only a mktemp fixture — fixtures contain only what you already thought of.
The coverage floor
A run that selected no source files is a critical finding, not CLEAN. 0 of 0 eligible source files scanned used to exit 0 — every rule passing by vacuous truth, in the one layer that decides
whether anything ran at all. A target with source but no SKILL.md is also reported, because P3
keys off it and its silence otherwise means nothing. P4 does not — the shared classifySkillFile
dispatches it for a skill or an agent file — so on an agents-only target the floor says so instead
of claiming a rule was un-run in the same report that prints its findings.
Symlinks are followed. A skill is delivered by symlink on this machine (~/.claude/skills/<name>
→ the repo), and Dirent.isFile() is false for a link, so the walk selected nothing and the probe
printed CLEAN over a skill it had never opened. Links are resolved and a dangling link is a
critical finding — lost coverage, named as such. Three limits keep that from becoming its own hole:
- The walk cannot climb. A link resolving to the target or above it (
sub/up -> ../.., or a link to$HOMEor/) is refused and reported, not followed. Otherwise the walk re-enters the tree from outside and enumerates everything beside it, and findings name paths the target does not own. A link pointing sideways out of the tree is still followed — that is the delivery case — and every file it reaches is declared incrossFileTargets. SKIP_DIRSdecides on what an entry resolves to, not what it is called:vendor -> node_modulesis skipped, anddist -> real-contentis walked rather than dropped in silence. A dangling link named for a skipped directory is skipped too, not reported as lost coverage the walk never wanted.- The cycle guard is an ancestor chain, not a visited set. A visited set also suppressed a sibling
alias of a directory already walked, and since rule dispatch keys on the path a file is reported
under,
agents -> sharedsilently decided whether the agent rules ran, on unsorted readdir order. Findings are deduplicated by real path, so one file reachable under two names is still one finding.
wc-probe.ts P1 is registration-driven: it reads the hooks: blocks of skill and agent
frontmatter plus any hooks.json / settings*.json, and refuses a registered hook command whose
body does not resolve on disk — a hook that never fires is indistinguishable from a passing one. It
does not flag a script that registers nothing, because scripts/ also holds probes. Nothing
detects an orphaned guard body — a script whose registration was deleted stays on disk unflagged,
and no rule looks for it. That is a deliberate gap, not a check happening elsewhere. A command naming a
variable the probe cannot substitute ($HOME, $CLAUDE_PROJECT_DIR) is not checked and says so
— it is an unknown path, not a broken one, and resolving it literally made every such registration a
false CRITICAL.
P5 follows the reference across files
A SKILL.md documents what its workflow returns; the return that implements it lives in the script
the file's Workflow({scriptPath}) names. They are different files, and no same-file rule could
ever compare them — in the spine corpus, 10 of 12 documented shapes describe a script the skill does
not own. So on Markdown, P5 binds each documented shape to the nearest preceding scriptPath, reads
that script's contract return — the return { at indentation zero, not a helper's early return
— and checks both directions:
| direction | fires when | gated on |
|---|---|---|
| documented, not implemented | a documented key is absent from the contract | nothing |
| implemented, not documented | a contract key is absent from the docs | the target resolved, exactly one top-level return, no top-level spread, and the documented shape exhaustive (every comma element parsed, no .../etc) |
Direction B is gated because each of those conditions is what makes "missing" knowable rather than merely unproven. When one fails, the check is skipped and reported, never silently passed.
One hop, no inference. P5 follows scriptPath and stops; it does not read what that script
itself calls. A bare Workflow({name: "..."}) is deliberately not resolved — inferring a path
the document never wrote produces confident findings about a script nobody named.
On .ts/.js the rule stays same-file, and it now recognises the three ways a file actually returns
an object: a literal return {…}, an arrow's implicit => ({…}), and a const r = {…}; return r
binding written exactly once. ES6 shorthand keys count in all three. Each of those was a
false-positive source, because a key the parser could not see reads exactly like a key nobody
implemented. Returns are split by paren depth: one at depth 0 is the enclosing function's
contract, one inside a call argument belongs to that callback and is reported as such rather than
counted or ignored.
An annotation lives in a comment. In code, P5 accepts a documented shape only from a comment —
not from a string or template literal. A return { inside a template is code a generator emits,
and the shape belongs to the emitted script, not to the emitter.
What P5 cannot decide for you. A file whose job is to specify code someone else writes — a
template stating the contract for an optional hook, a design doc for an artifact the build produces —
documents a shape it never implements, and no rule can tell that apart from a contract that rotted.
Declare it: <!-- wc-probe: ignore-returns -->, file- or region-scoped, which is printed on every
run. Inferring it instead would mean guessing, and a wrong guess fails silent — the vacuous pass
references/gate-laws.md L2(a) forbids.
P12 dispatch routing — how a skill that emits a work-args fence hands it to work
P10 and P11 judge what a work-args fence declares; P12 judges how the file dispatches it. Two clauses, over any file emitting at least one fence:
- (a) CRITICAL — the file names some other runner script and never names
work-dispatch.sh. That script iswork's entry point and owns the gates that run before the workflow does — TIER 1plan-lint, the TIER 2redCommandprobe, TIER 2bplan-preflight. A hand-rolled runner line skips all three, and the skip leaves no trace afterwards: the run simply proceeds ungated. Keyed on the shape of a runner reference (any<name>.sh|.ts|.mjs|.json a line claiming the dispatch), never on one runner's filename — a name-keyed rule retires itself silently the day the runner is renamed. - (b) MAJOR — a fence whose
projectDirliteral lies outside the repository containing the file, with no--run-diranywhere in it.workthen writesargs,resultandloginto a.work/inside a tree the run was only meant to read.
A file naming neither entry point is not judged: delegating without naming one is
indistinguishable from documenting nothing, and inferring a script nobody wrote produces confident
findings about it. Marker: dispatch.
P13 task-row coverage — every instance the gate judges has a task row that builds it
A fan-out workflow names its instances twice over: as --lecture NN: specs inside the one
mechanicalChecks command, and as scoredChecks[].items lines. MAJOR for each id a fence
enumerates in either that no tasks[].id covers — the gate then reports on an artifact no
implementer was dispatched to produce. A task id covers an instance when it carries that id as a
whole run of digits, so content-18 and r18-align cover 18 while deck-190 does not cover 19.
The id-space is digits only, and a lens is not a source. It was one while a workflow could declare one lens per instance and encode the instance in that lens key's numeric suffix; there is now exactly one lens per fence and it has no key, so the only ids it could yield would be digits scraped out of a prompt, which is not an enumeration.
A fence whose tasks[] is empty or absent is skipped entirely — that is what a readOnly charter
declares, and flagging it would fire on every audit block. Marker: task-coverage.
P14-P18 round-shape rules
| rule | fires on | severity | marker |
|---|---|---|---|
| P14 shell-only agent leg | a scoredChecks, attempt or lens prompt, or a code agent( call, told to run a command as written AND report its exit code |
major | shell-leg |
| P15 duplicate grading | against the wired rules of the fence's ruleChecks --rules dirs: a scoredChecks penalty count named for a rule, or a lens sentence that does not defer and names the rule id or its name word in capitals |
major | duplicate-grading |
| P16 missing ruleChecks | a fence whose tasks[].writablePaths map through ruleSetOf (hooks/jev/rules.ts) to a set with wired rules, carrying no ruleChecks |
major | rule-checks |
| P17 serial row loop | a task work, attempt or farm-row prompt looping over plural independent items with no dependency named |
minor, advisory | serial-loop |
| P18 fixed shared temp path | a worktree add/remove or recursive delete at a literal temp path with no per-run part, on the line or through one assignment | major | fixed-temp |
Advisory rules print as [advisory <severity>] in both modes and never move the exit code. P15 gates since its 2026-10-02 baseline (15 findings, ds lens and teaching notes scoredChecks) was cleared.
Declared exemptions
A check may be suppressed only by a marker that is the whole trimmed line, optionally behind a
//, # or * comment lead-in:
| marker | effect |
|---|---|
<!-- wc-probe: ignore-<rule> --> |
whole file |
<!-- wc-probe: ignore-<rule>:start --> … :end |
that region (unclosed runs to EOF) |
<rule> is one of all, hooks, paths, returns, workflow-refs, refs, entry-point,
dispatch, task-coverage, shell-leg, duplicate-grading, rule-checks, serial-loop,
fixed-temp — the rules that are actually honoured.
Any other name is a P9 exemption vocabulary finding rather than a silent
no-op: a marker that reads as a suppression and is not one hides a failing check twice over.
Markers are parsed once, from the raw file, and threaded into every predicate, so what suppresses
and what gets reported cannot disagree. A marker inside a fence — or indented four spaces — is an
example, not a declaration.
P11 is deliberately outside this grammar. It polices the one file per skill that emits two
work-args fences, so a whole-file ignore- marker would disable it exactly where it is the only
rule. Its intended differences are named instead:
<!-- wc-probe: lens-set-differs <field> [<field>...] -->, listing the lens fields allowed to
differ (prompt, refs, agentType). Any
difference it does not name is still a finding, and the declaration prints like an exemption without
being one.
Every exemption applied, every unresolvable reference skipped, and every file read outside --target
is printed in both output modes. The unit is the exemption ENTRY — notesFor() emits one
SUPPRESSED line per entry, whole-file or region-scoped alike, so two region markers for one rule
in one file would print twice. This skill declares five entries across four files — one of them
region-scoped, so entries are the count that must match, not rule×file pairs. All five print on every
run, alongside SKILL.md's lens-set-differs prompt declaration, which is not one of them:
| file | rules | entries | why |
|---|---|---|---|
references/hook-reach.md |
paths |
1 | the evidence IS an unsubstituted placeholder; resolving it destroys the finding |
scripts/wc-probe.test.ts |
paths, returns |
2 | its fixtures are deliberately broken paths and fake return shapes |
scripts/parity-check.sh |
paths |
1 | its fixture writes a deliberately broken plugin-root reference for both surfaces to disagree about |
scripts/check.test.ts |
paths (region) |
1 | two fixture constants are deliberately broken references the wc-probe leg must fire on |
Red flags
| Situation | Wrong move | Right move |
|---|---|---|
| User asks what is wrong with a workflow | hand-dispatch a review as a bare Agent call to skip the plan-mode ceremony |
audit-only is the work loop with readOnly: true and tasks: [] — same spine, same carried-finding adjudication, same computed gate. A charter plan is the price, and work says pay it |
| Audit turned up fixes | fix them inside the audit | that is an improve run with its own plan and its own gate |
| The lens keeps flagging the same shape, or a checklist item is decidable | tune the prompt and keep the item | that shape is a lint rule nobody has written yet — write the rule, make it a leg of the entry point, delete the item from the lens prompt. path-resolution was deleted this way; it is P1/P2/P4/P6. The lens is what is left when no exit code can decide it |
| Another deterministic check is needed | add a second mechanicalChecks entry beside the first |
add a leg to check.sh — the entry point's exit code is the whole mechanical verdict, a check declared beside it is one nothing reports when it is dropped, and P10 refuses the second entry unless <!-- wc-probe: ignore-entry-point --> says why |
| Generated workflow needs a lifecycle | write a second spine inside the skill | supply parameters to an existing spine; a skill that re-derives a lifecycle is the finding spine-fidelity looks for |
| Referencing a domain workflow | by its meta.name |
{scriptPath: "<absolute path>"} — a bare name resolves only through .claude/workflows/ |
Dispatching work (or any workflow script) |
the built-in Workflow tool |
the guard at ~/.claude/hooks/main-thread-guard.sh denies Workflow unconditionally, so the call never runs — go through farm.sh --workflow … --args … --out … and validate the result file with work-result.sh. Same guard denies Agent for non-allowlisted subagent types; Explore and Plan stay allowlisted |
| Write-time guard needed on an implementer | reach for a hooks: guard before asking whether a tools: allowlist states the same restriction; or declare it in the generated SKILL.md frontmatter, in an agent file under <skill>/agents/, or in a PLUGIN-shipped agent's frontmatter |
the SKILL.md hook was observed not to reach dispatched agents; <skill>/agents/ is not a discovery location so nothing registers there; and hooks: is IGNORED for plugin-shipped agents, which registers an agent whose guard never fires — put it in an agent's frontmatter under .claude/agents/ or ~/.claude/agents/ and thread it via implementerAgentType, or, inside a plugin, in that plugin's hooks/hooks.json. An allowlist PREVENTS the write and a hook only reacts to it, so a restriction expressible as tools: should never be a hook — a hook needs reach to work, an allowlist needs none |
| Guard is in the agent's frontmatter, so the implementer will read what it prints | assume it prints and is read | a blocking guard (exit 2 + stderr) is measured to reach the implementer verbatim; advisory output while exiting 0 is not — make the guard block, or have a mechanicalCheck re-derive the finding at gate time |
| Hook command needs the skill directory | CLAUDE_SKILL_DIR in hooks: |
it does not substitute there — absolute path. P1 now refuses it, because the hook silently never fires |
| Probe run came back CLEAN | trust it | check the coverage line — 0 of 0 is now a critical, but so is a subtree behind a dangling symlink |
| Task or lens has no domain rules | omit refs |
refs: [] — absent is refused, empty is a statement |
| A documented return shape drifts from the script | assume P5 caught it because the suite is green | P5 compares a SKILL.md's shape against the script its scriptPath names — check the crossFileTargets note to see which file the verdict was actually about |
| A check needs suppressing | invent a rule name for the marker | only all/hooks/paths/returns/workflow-refs/refs/entry-point/dispatch/task-coverage/shell-leg/duplicate-grading/rule-checks/serial-loop/fixed-temp are honoured; anything else is a P9 finding, not a suppression. P11 has no ignore- form at all — declare the intended difference with lens-set-differs <field> |
A skill dispatches work with its own hand-rolled runner line |
copy the invocation into the SKILL.md | that skips the gates work-dispatch.sh owns on the way in, and P12 refuses it |
| Gate says PASS and the probe was skipped | read mechanicalRun: 0 as clean |
nothing was checked; re-run with the checks present |
FAIL with an empty tasksThatFlagged |
conclude there is nothing to fix | read mechanicalThatFailed, lensesThatFlagged and planFindings too — the selector has four channels and only one of them owns tasks. A blocking lens finding the lens could route to no task lands in planFindings and is fixed by amending the plan. Only when all four are empty does an empty selector on a failing run mean re-run everything |
| A generated gate field is filled in by the agent that did the work | ship it | pair it with a deterministic JS check or a separate low-effort probe — self-report is not a gate |
The workflow needs something work doesn't obviously do |
write a workflow.js for it |
ask which work parameter is missing and generalize work. That urge produced mechanicalChecks, refs, authorityExtra and the agentType overrides — each now available to every workflow. A .js only after that answers "none", justified in the plan |
A domain .js was justified, and node --check passes on it |
call it tested | it is a syntax check; smoke it with bogus args and confirm it reaches its own arg-validation error |
| Whole-artifact failure in the generated gate | let it flip overallPass with no selector |
give it its own selector channel and make the fix loop consume every channel the gate returns — work's own returns four, so a generated gate that adds one has five |