workflow-authoring
Use when writing or debugging a saved Claude Code Workflow (a .js/.mjs script under ~/.claude/workflows using the injected agent/parallel/phase globals to fan out subagents). Covers the runtime API, banned Date.now() non-determinism, worktree isolation, per-fan-in schemas, null-tolerant barriers, and the typed Codex output-schema landmine. Triggers: author a workflow, fan out subagents.
- License 许可
- MIT
- 最近更新
- 2026-11-02
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- workflow-authoring
- description
- Use when writing or debugging a saved Claude Code Workflow (a .js/.mjs script under ~/.claude/workflows using the injected agent/parallel/phase globals to fan out subagents). Covers the runtime API, banned Date.now() non-determinism, worktree isolation, per-fan-in schemas, null-tolerant barriers, and the typed Codex output-schema landmine. Triggers: author a workflow, fan out subagents.
02 资源文件 9
- 参考文档DESCRIPTION.md147 B
- 脚本scripts/extract_marker.py3.0 KB
- 脚本scripts/extract_pymupdf.py3.1 KB
- 脚本scripts/test_workflow_lint.py43.4 KB
- 脚本scripts/workflow_lint.py38.7 KB
- 参考文档templates/governed-run-orchestrator.md157.1 KB
- 参考文档templates/h6-timeout-heartbeat.md10.4 KB
- 参考文档templates/resume-wired-fanout.md10.9 KB
- 脚本tests/test_governed_run_template.py4.7 KB
03 SKILL.md 正文
workflow-authoring
How to write a saved Claude Code Workflow — an orchestration script under
~/.claude/workflows/*.js (or *.mjs) that the runtime executes to fan out
subagents, collect their typed results, and return a structured answer. This is
the durable, re-invokable form of a multi-agent job (invoked as /name), as
opposed to a one-off Task() subagent.
The rules below are distilled from the workspace's own recorded footguns
(feedback_workflow_orchestration, feedback_parallel_agent_worktree, the
"Workflow args-as-string gotcha") and the G.6 gem-hunt finds. Follow them and
your workflow will not deadlock, will reproduce, and will not contaminate the
shared git index.
1. The runtime API (the whole surface)
A workflow file is NOT a plain ES module. The runner injects eight globals —
agent, parallel, pipeline, phase, log, args, budget, workflow
(plus console, setTimeout, clearTimeout) — reads your export const meta,
wraps the body so top-level await and a trailing top-level return are legal,
and executes it in a Node vm context.
CORRECTED 2026-08-12. This section previously said "injects three globals —
agent,parallel,phase". That was measured wrong against the installed runtime, and the omission ofpipelineis not cosmetic: 0 of the 10 saved workflows on this box usepipeline()while 15 barrier calls do, even though the runtime's own tool doc says "DEFAULT TO pipeline()". The globals list, theargscontract (rule 2), and the effort enum below were all re-derived from the installed binary; seedocs/workflow-sota/GROUNDING-NOTES.md§3 and the verbatim dump atdocs/workflow-sota/RUNTIME-API-DUMP.txt.
The sandbox is real, not advisory: codeGeneration.strings = false (no eval,
no new Function), no module loader (require is undefined, import() throws),
no filesystem or Node API, and Date.now() / argless new Date() / Math.random()
are shimmed to throw — not merely discouraged. runInContext caps only the
synchronous body at 30 s; it does not bound an awaited agent, which is why
per-agent timeouts are your problem (rule 5b).
export const meta = {
name: 'my-workflow', // the /slash name; keep it stable
description: 'one line — shown in the picker',
phases: [{ title: 'Ground' }, { title: 'Fan-out' }, { title: 'Synthesize' }],
}
phase('Ground') // declare/enter a phase (telemetry)
const SCHEMA = { type: 'object', additionalProperties: false,
required: ['answer'], properties: { answer: { type: 'string' } } }
const ground = await agent('prompt string', {
label: 'ground', // stable id for this agent (used in logs/telemetry)
phase: 'Ground', // which declared phase this belongs to
schema: SCHEMA, // JSON-schema; the runtime RETURNS a parsed object
effort: 'high', // 'low' | 'medium' | 'high' | 'xhigh' | 'max'
model: 'opus', // 'opus' (default, deepest) or 'fable' for wide fan-out
})
phase('Fan-out')
const results = (await parallel(
items.map((it) => () => withTimeout( // <-- paste-in helper, NOT a global; rule 5b
() => agent(promptFor(it), { label: `lens:${it.key}`, phase: 'Fan-out', schema: SCHEMA }),
600000, `lens:${it.key}`))
)).filter(Boolean) // <-- the null-tolerant barrier; see rule 5
return { ...ground, results } // top-level return is the workflow's result
agent(prompt, opts)→ a Promise of the parsed schema object (not raw text). Always pass aschemaso you get typed fields, never prose to re-parse.parallel(thunks)→ a Promise of an array, one entry per thunk, in order. Pass an array of zero-arg thunks (() => agent(...)), not an array of already-started promises — the thunks let the runtime schedule/limit them.phase(title)is telemetry/structure; call it once per phase before its work.pipeline(items, stage1, stage2, ...)→ runs each item through all stages independently, with no barrier between stages. Item A can be in stage 3 while item B is still in stage 1. Wall-clock = the slowest single-item chain, not the sum of the slowest-per-stage. Every stage callback receives(prevResult, originalItem, index). A stage that throws drops that item tonulland skips its remaining stages. This is the default for multi-stage work. Caveat measured elsewhere:pipeline()is not a cacheable resume unit — resuming a stopped pipeline re-runs its first stage (skills/workflow-foundry/SKILL.mdL4).log(msg)→ a progress line in the run view. The only output channel from the script body itself.budget→{ total: number|null, spent(): number, remaining(): number }. The target is a HARD ceiling: oncespent() >= total, furtheragent()calls throw.totalisnullwhen the user set no target, andremaining()is thenInfinity— so always guard a budget loop onbudget.total(rule 5c).workflow(nameOrRef, args?)→ run another saved workflow inline. The child shares this run's concurrency cap, agent counter, abort signal and budget. Nesting is one level only;workflow()inside a child throws.- Concurrency is capped at
min(16, cores - 2)per workflow (14 on this box), with a 1000-agent lifetime cap as a runaway backstop. - Native resume:
Workflow({ scriptPath, resumeFromRunId })replays the longest unchanged prefix ofagent()calls from cache. Same script + same args = 100% cache hit. Before diagnosing an empty result, read<transcriptDir>/journal.jsonl— it records each agent's actual return value.
2. args is delivered VERBATIM (corrected — the old "one string" rule was false)
This rule previously said the opposite and the linter enforced it. Measured against the installed runtime's own tool doc,
argsis "the value passed as Workflow'sargsinput, verbatim (undefined if not provided)". Pass a JSON array and the script receives an array. The lint check was renamedargs-parse-if-string→args-unguardedand re-messaged.
Two things are still true and are the actual footguns:
argsisundefinedwhen the workflow is invoked with none, so a bareargs.topicthrows a TypeError. Guard it:const a = args ?? {}.- A caller who passes a JSON-encoded string instead of a JSON value does
reach the script with one string, and
args.filter/args.mapthen throw. That is a caller-side mistake, not the runtime contract; addtypeof args === 'string' ? JSON.parse(args) : argswhen a caller may do it.
Independently of both: design for there being no reliable arg channel at all — have the agents auto-discover their input from live files (Read/Glob). The saved gate Workflows do exactly this: they tell each reviewer where to find the plan/design doc and let it locate + Read it.
3. Never call Date.now() (or Math.random(), new Date()) in workflow JS
Non-determinism in the orchestration layer breaks two things: prompt-cache
stability (a changing timestamp in a shared prefix busts the cache for every
downstream agent — see rule 8) and reproducibility (two runs of the same
workflow diverge for no reason). If you genuinely need a timestamp or a random
id (e.g. to name a shadow-log line), generate it inside an agent's tool call
(Get-Date -Format o in PowerShell, date -Is in a POSIX shell) — never in the
workflow's own JavaScript. The gate Workflows produce their shadow-sink timestamp
this way for precisely this reason.
4. isolation: "worktree" for any agent that MUTATES files
Parallel subagents share ONE git index and working tree by default. A broad
git add -A in one agent then sweeps a sibling's in-flight files into the wrong
commit (feedback_parallel_agent_worktree). Any agent that WRITES to the repo
must run isolated:
agent(prompt, { label: 'impl:WU3', schema: S, isolation: 'worktree' })
Read-only agents (reviewers, researchers, the gate reviewers) need no worktree — declare them read-only in the prompt and skip the overhead. Pair worktree isolation with disjoint file scopes (rule 7) so two mutating agents can never target the same file.
5. Null-tolerant barriers: .filter(Boolean) after every parallel
parallel() has no per-agent timeout and a throwing thunk resolves to
null rather than rejecting the whole batch. Two consequences:
- Always
.filter(Boolean)the result so one dead agent does not poison the aggregation with anullyou then dereference. - One HUNG (not thrown — actually stuck) agent will still stall the whole
phase until it dies, because the barrier waits for all thunks. That is the
recorded barrier-deadlock (
feedback_workflow_orchestration). The structural fix is rule 6.
const reviews = (await parallel(reviewers.map((r) => () =>
withTimeout(() => agent(r.prompt, { label: `review:${r.role}`, schema: REVIEW_SCHEMA }),
600000, `review:${r.role}`) // rule 5b: bound each thunk (H6 paste-in)
))).filter(Boolean) // a throwing reviewer becomes null -> dropped, phase completes
5b. Bound every fanned-out agent — design item H6 (SHIPPED)
Rule 5 says a thrown agent becomes null. A hung one is worse: the barrier
waits forever. That is not hypothetical here — a 13-hour deadlock and 11 lanes
stalled behind one hung reviewer are both on the record, and
platform/workflows/shared-prod-shard-fanout-build.json records an 11-hour hung
production unit. Measured 2026-08-12: 15 of 15 barrier calls across all 10 saved
workflows had zero timeout protection (Promise.race 0, setTimeout 0,
AbortController 0).
The fix ships as a paste-in block, because the sandbox has no module loader and a helper therefore cannot be imported:
templates/h6-timeout-heartbeat.md — withTimeout(thunk, ms, label) and
settle(thunks, ms, labels). settle() is a drop-in for parallel() that
returns an index-aligned array with no nulls and no holes: every slot is
either a real result or a typed drop-receipt { __h6: true, __endReason: 'timeout' | 'threw' | 'null', label, ms }.
Type the drop, do not filter it away (design item H3): the runtime yields null
for at least three different causes — the user skipped the agent, the subagent
died on a terminal error, and (with H6) our own timeout — and .filter(Boolean)
erases that distinction, which is what makes "12 of 15 lenses returned"
unauditable today.
Honest scope, do not widen it. Promise.race unblocks the barrier; it
does not kill the agent. The dropped agent keeps running, keeps one of the 14
concurrency slots, and keeps spending budget until it dies on its own. The
design doc's word "killed" (B3-migration-verification.md:262) is an overclaim
against this mechanism. Settling experiment: read budget.spent() at the timeout
receipt and again at workflow end; a rise proves the agent lived on.
Red-proof (tools/wf-timeout/test_h6_timeout.py, 20 tests): the helper is
extracted from the template and executed in a Node vm context configured like
the real sandbox. The known-broken control — a bare parallel() over the same
five thunks, one never resolving — is required to report hung: true (measured:
1507.9 ms, watchdog); settle() over the identical thunks returns in 175.0 ms
with exactly one timeout receipt and four intact survivors.
5c. Guard every budget loop on budget.total
budget.total is null when the user set no token target, and remaining() is
then Infinity. An unguarded while (budget.remaining() > 50_000) therefore runs
straight to the 1000-agent lifetime cap. Write
while (budget.total && budget.remaining() > 50_000). Linted as
budget-loop-unguarded.
6. Per-work-unit pipelines, not one fat barrier
Prefer pipeline()-style per-WU flows (impl → validate → review → commit for
each WU independently) over a single wide barrier that waits for the slowest WU
before ANY progress. A per-WU pipeline means a slow or stuck WU idles only
itself, not the whole cohort — and it composes with the cross-session resume
manifest (rule 10) so a died WU is re-driven while its siblings keep their
completed state. When you must use a wide parallel, keep the batch small and
homogeneous (similar expected runtimes) so the barrier's tail is short.
7. One-level nesting + a schema on EVERY fan-in
- One level of nesting.
parallel([... agent ...])at the top is fine. Nestingparallelinside anagentinside anotherparallelmakes runtimes, failures, and telemetry impossible to reason about — flatten into sequential phases instead. - Schema on every fan-in. Every
agent()that feeds an aggregation MUST carry aschema(withadditionalProperties: falseand an explicitrequired). You then aggregate typed objects, never re-parse prose. The aggregator agent also takes a schema. This is what makes the gates "schema-typed":{ verdict, blocking, ... }on each reviewer. requiredis the FLOOR, not the wishlist — over-broadrequired[]rejects good work. List inrequiredONLY the fields an agent can ALWAYS produce (typicallydigest/answer+evidence). Anything conditional — averdicta null-result unit has no basis for, anartifactpath a read-only lens never writes, anowner_actionsarray that is legitimately empty — must be OPTIONAL. A required field the agent cannot fill makes StructuredOutput reject an otherwise-correct return and burn all its retries. Ledger-proven 2026-07-19 (wf_00a25df9-2cd): aDIGESTrequiringverdict/corrections/evidence/artifactfailed 2 agents 5× each — their real analysis (one wrote a 16 KB artifact to disk) was thrown away purely on the digest shape. Belt-and-braces: have each agent WRITE its full deliverable to disk (rule 11 output contract) so a rejected digest never loses the work — the synthesis fan-in reads the artifact, not the digest.- Editing a shared schema const busts the RESUME cache — plan the schema before
the first launch. Resume is prefix-cached: a completed
agent()replays only if its(prompt, opts)is byte-identical, andopts.schemais part of that. A schema referenced by the FIRST phase's agents is therefore the worst thing to edit on a resume — it changes every agent'sopts, so the longest-unchanged prefix is empty and ALL agents re-run live (a full "from beginning" run you may have been explicitly told to avoid). Corollary: get the schema right up front; if you must fix an over-strict schema mid-run, the cache-preserving move is a ONE-OFF standaloneTask()for the single failed lens (writing the same artifact path), NOT an edit-and-resume of the shared const.
8. Cache-cliff-aware fan-out
For a wide fan-out (many lenses/WUs), keep the shared prompt prefix
byte-stable across all agents so the prompt cache is hit N-1 times instead of
paid N times. Concretely: build one immutable CONTEXT / PROBLEM string and
prepend it identically to every agent prompt (only the per-agent tail varies);
do not interpolate anything volatile (timestamps, random ids, re-serialised JSON
with unstable key order) into that prefix — that is the cache cliff, and it is
the other reason rule 3 bans Date.now(). Pack a repo once (repomix) and let N
agents grep the pack rather than each re-reading the tree.
9. Typed Codex --output-schema landmine (#15451)
When a workflow fans out to Codex (via the mounted codex mcp-server /
codex exec), a typed --output-schema is silently ignored if any MCP tools
are active in that Codex invocation (OpenAI codex issue #15451). You will get
free-form text back and your schema parse will fail or mis-fill. Mitigations:
run the schema-typed Codex lane with MCP tools disabled for that call, or
validate/repair Codex's output on the Claude side before aggregating. Do not
assume the schema was honored just because you passed it.
Windows arg-limit corollary (measured 2026-07-18). The same active-MCP inventory that voids
--output-schema also blows the Windows 32,767-char CreateProcess limit: two Codex runs died
with payload_len=34472 / os error 206 before executing anything. Pass --ignore-user-config
on every non-interactive Codex invocation, and pass the prompt BY FILE (--prompt-file, or stdin
via -), never inline. See rules/common/cross-vendor-dispatch.md.
10. SDK notes (context-editing, task-budgets, canUseTool)
When a workflow is driven through the Agent SDK rather than the in-app runtime:
- context-editing (
clear_tool_uses) trims tool-call history to fit long fan-outs — enable it for long-running orchestrations so agents do not blow the window on accumulated tool noise. - task budgets cap per-agent spend; set them on mechanical/harvest agents so a runaway agent cannot drain the wave.
canUseToolis the SDK's per-agent tool gate — the file-scope / capability enforcement point for fan-out subagents (the parity for in-app subagent scoping). Use it to hold a mutating agent to its declared file scope (rule 4/7).
11. Fan-out spawn-prompt template
Measured over 50 agents / 6 workflows (2026-07-18): element presence in the prompt predicts
compliance almost perfectly, and nothing is inherited from the global contract. Blocks marked
REQUIRED are the ones with measured evidence behind them. Full rationale + the fan-in gates:
rules/common/fan-out-contract.md.
[REQUIRED] VERIFIED GROUND TRUTH — established by direct inspection immediately before this
workflow. Treat as high-confidence. Each claim is tagged [probe: <how it was measured>].
To confirm OR refute you MUST use a mechanically DIFFERENT probe — a different shell, a
different data field, a different measurement. Re-running the tagged probe does NOT count as
verification and must not be reported as CONFIRMED. Report every reconciliation in a
dedicated section, whatever the outcome.
1. <claim> [probe: Get-Command mon, PowerShell 7]
2. <claim> [probe: directory listing at 00:25 — may be stale]
...
[REQUIRED] ACCESS METHODS — for every path above that is NOT readable by Read at that literal
path, state how to read it:
- tools/monitorcore/** exists ONLY on origin/main: read via `git show origin/main:<path>`.
The working tree is on branch <X> and does not contain it.
[REQUIRED] ANTI-OVERFIT ANCHOR — a proposal counts only if it (a) rides the EXISTING bus
(<name the buses>), and (b) does not reintroduce a rejected component (<name them>).
Prefer "enable a native feature" and "fold into the existing adapter" over "new daemon".
[If a deliverable deliberately waives this, say so explicitly and say why.]
ENVIRONMENT: <OS, shells, language constraints, RAM ceiling>.
=== YOUR TASK (<lens-slug>) ===
<2-5 sentences. Name the concrete artefacts to read. State the deliverable as numbered
outputs. End with "Be concrete about <the specific axis>, not generic <domain> advice.">
[REQUIRED] PER-FINDING RUBRIC — apply to every RECOMMENDATION (skip for pure audit findings):
name / what it is / why THIS user specifically / how to build-or-adopt (file, function,
command) / source: file:line or URL / value (H|M|L) x effort (H|M|L) /
disposition: NATIVE (already exists, just enable) | ADOPT (external, vendor it) |
BUILD (write it) | PRUNE (delete something) /
confidence: almost-certain 90-99% | likely 55-80% | roughly-even 45-55% | unlikely 20-45%,
followed by the evidence that justifies that band.
[REQUIRED] HONESTY CONTRACT — the global contract does NOT carry into this fan-out; these
apply here or not at all:
- Attach UNVERIFIED INLINE to the sentence it qualifies. A limitations section at the end
does NOT satisfy this. If a recommendation's premise is UNVERIFIED, its heading inherits
the tag.
- Every UNVERIFIED names the experiment that would settle it.
- "Absent / not found / broken" is a claim, not an observation. Verify your detector can
find a known-present item before reporting absence.
- Do not report CONFIRMED on a probe that is the same mechanism as the one being checked.
- If a tool is denied, you are in a subagent — no user will answer. Do not stop. Record the
gap, use an alternative, report it in your digest.
[REQUIRED] OUTPUT CONTRACT
1. Write the full deliverable to <abs path>. Write it with the **Write** tool. If a single
call is too large, chunk with `Add-Content` — never build a multi-KB literal inside a
shell command string (Windows 32,767-char limit -> os error 206).
2. Then return a <= 400 word structured digest as your final text (NOT the whole doc).
LEAD the digest with any ground-truth correction, then the top findings.
3. Append an EVIDENCE: block of 3-5 file:line / URL anchors. It is EXEMPT from the word
cap — 87% of file:line refs and 100% of URLs otherwise die at the digest boundary.
4. Your final line must be: DELIVERED: <abs path> (<n> bytes)
For a cross-vendor (Codex) unit, do NOT paste a here-string output contract and do NOT route
through a *-rescue forwarder — see §12 and rules/common/cross-vendor-dispatch.md.
12. agentType caveat — a fan-out is only as synchronous as its slowest lie
agent({ agentType: 'codex:codex-rescue' }) is NOT an interchangeable work-unit executor. Its
definition instructs it to prefer background execution for tasks that are "complicated,
open-ended, multi-step" — i.e. every work unit — and it ships model: sonnet with tools: Bash
only, so it cannot write a report or verify its own artifacts. Measured: 3 such agents, 34 turns,
0 production files; one returned only a job id, and that job then completed on disk with a
real finding the harness could not collect. Use agentType: 'codex' (the synchronous adapter).
Any barrier that accepts a result containing started in the background or Check /codex:status
is accepting a receipt, not an answer — reject it at fan-in.
13. Wire the resume manifest — the cross-session half of rule 6
plan_lint_stage0.py has shipped a complete, fsync'd ResumeManifest since 2026-07-12 and, as
measured 2026-07-29, had zero callers: the only reference anywhere was the LINTER const at
plan-gate.mjs:35, which runs lint() and never touches the manifest. Consequence — every saved
Workflow that dies mid-wave redoes the whole wave.
Wire it with an agent() step, never a JS shim. The runner wraps the body (rule 1), so a
static top-level import is a SyntaxError; measured across all 11 saved workflows there is not one
import, require(, child_process or fs. call — every disk read is delegated to an agent's
shell. Copy templates/resume-wired-fanout.md (its js block is kept lint-clean and is gated by
scripts/test_workflow_lint.py). Three touch-points, nothing else:
| when | command | effect |
|---|---|---|
| GROUND, once | --manifest M --ids <all> --next |
stdout = exactly the ids still to run, one per line |
| BEFORE each unit | --manifest M --mark-partial <id> |
write-ahead; an fsync'd partial record |
| AFTER each unit | --manifest M --mark-done <id> / --mark-failed <id> |
terminal state |
--ids and --next exist because a Workflow's unit list is a JS array: resume() derives
pending from a declared work-list, and the CLI's only other source was --plan, which reads a
plan document. So an author had to synthesise a throwaway plan.wu.json just to name their own
units — that friction is the mechanical reason the manifest shipped uncalled.
Durability, measured (not assumed): with --mark-partial WU3 written, a taskkill /F on the unit
process, and --resume in a fresh interpreter, WU3 lands in partial — process-crash recovery is
executable-proven (13/13 assertions, and 3/3 mutants of the fold/append/torn-line logic go RED,
so the probe is not vacuous). UNVERIFIED, inline: machine-crash / power-loss durability is NOT
proven — a process kill cannot distinguish fsync'd bytes from page-cache bytes, so the
experiment cannot see the fsync at all (only an instrumented counter shows the call is reached,
FSYNC_CALLS=1 per append). Settling experiment: run the same protocol in a VM and hard-cut power
between the append and the read.
Two honest limits to design around: --mark-done is asserted by the unit agent, not verified by
the orchestrator, so pair it with an independent postcondition probe (artifact exists AND ends with
your completion marker) exactly as the accumulator workflow's AND-gate does — a unit that lies is
skipped forever, which is worse than being re-driven. And done.jsonl is append-only and never
pruned; records() re-reads the whole file, which is fine at wave scale and is not a database.
Governed single-shot run
Everything above is how to write the workflow JS. When you instead want to GOVERN a whole run
end-to-end from a single invocation — ground → design/plan gates → fan-out → integrate → reconcile
→ one focused merge-when-green PR → memory sync — this skill vendors a reusable orchestrator
meta-prompt: templates/governed-run-orchestrator.md.
It is a fill-in template, not a second engine — it rides the native Claude Code Workflow tool and this skill's bus. Use it in four steps:
- Fill — copy the template and replace ONLY its two authoritative slots.
<GOAL>is the durable outcome + named scope + resume-state + exclusions;<DoD>is the numbered completion AND-gate. Add as manyDOD-NNrows as the goal needs, each an externally observable state with TWO mechanically independent evidence sources. Remove every[...]instructional sentinel; keep the<GOAL>/<DoD>tag names and theDOD-NNid format. - Freeze — freeze the filled
<GOAL>/<DoD>, the artifact root, the schemas, and the prompt hashes before the first unit launches, and never rewrite them mid-run (fan-out contract §A10). - Launch — invoke
/workflowand paste the whole filled prompt as its single raw string argument./workflowtakes ONE string; do not invent--goal/--dodflags (see §2, args-as-string). - Terminal marker — the run ends with exactly one mechanically derived
SUCCESS:<workflow-id>(only when everyDOD-NNpasses with both evidence records) ORFAILED:<workflow-id> <reason>(with every unmet id, preserved artifacts/branches, the exact blocker, and one resumable next action). A progress report, a job id, "started in the background", or "check status" is NEVER terminal success.
This complements, and does not duplicate, the neighbouring orchestration skills: the
cross-vendor architect/editor split, the sandbox ladder, and retry-escalation live in
../orchestration-patterns/SKILL.md; the large
parallel-agent-on-Databricks fan-out lives in
../codex-databricks-fanout/SKILL.md. Author the workflow
JS with §1–§12 above; reach for the template only to govern an entire run.
Lint your workflow
Before saving, run the bundled static linter over your workflow file(s):
python skills/workflow-authoring/scripts/workflow_lint.py ~/.claude/workflows/my-wf.mjs
python skills/workflow-authoring/scripts/workflow_lint.py ~/.claude/workflows # or a whole dir
It HARD-FAILS (non-zero exit) on the determinism-breakers that silently corrupt resume + the prompt cache, and WARNS on the recorded footguns — every check maps to a rule above:
| level | check | rule |
|---|---|---|
| ERROR | no-date-now · no-math-random · no-argless-new-date |
3 — banned non-determinism (Date.now() / Math.random() / argless new Date()) |
| ERROR | meta-missing · meta-not-object · meta-not-literal |
1 + 8 — a pure-literal export const meta |
| ERROR | sandbox-forbidden |
1 — require / import / eval / new Function / process.*, none of which exist in the vm sandbox |
| WARN | args-unguarded |
2 — args.<field> with no absent-args guard (renamed from args-parse-if-string, whose premise was measured false) |
| WARN | agent-schema |
7 — an agent() fan-in with no schema: option |
| WARN | parallel-filter-boolean |
5 — a parallel() result that is neither .filter(Boolean)-ed nor null-typed by a .map() |
| WARN | barrier-without-timeout |
5b — a parallel() over agent() with no withTimeout / settle / Promise.race (design item H6) |
| WARN | prefer-pipeline |
6 — two barriers separated only by a pure transform; the runtime's own smell test |
| WARN | budget-loop-unguarded |
5c — while (budget.remaining() …) with no budget.total guard |
Measured against the 10 saved workflows on 2026-08-12: the pre-change linter
reported 12 findings (11 agent-schema + 1 parallel-filter-boolean); the
updated one reports 27 — a delta of exactly the 15 barrier-without-timeout
hits, one for every barrier call on the box, with no change to any pre-existing
check. prefer-pipeline fires 0 times on that corpus (the live workflows use
one barrier per phase, not the two-barrier-with-transform smell), so its only
current evidence is its both-states fixture — it is a prospective rule, not a
demonstrated catch.
--strict also fails on warnings; --json emits machine-readable findings with file:line.
The linter is a stdlib-only lexical pass — it MASKS comment + string/template bodies before
matching, so a banned call quoted in a string never false-fires — plus a recursive-descent
literal parser for meta. It is not a full JS parser; the honest limits are in the module
docstring (e.g. options passed to agent() via a variable read as a false WARN; Date.now()
hidden in a nested template interpolation may be missed). Treat ERRORs as blocking, WARNs as
advisory. Tests live beside it (scripts/test_workflow_lint.py) and run in CI at 100% branch
coverage.
Author quick-wins (helper snippets)
Copy-paste patterns that make the checks above pass. These are DOC snippets — the runtime API (section 1) is unchanged.
Parse-if-string args (rule 2). args arrives as ONE string. Normalise it once, then read
fields off the parsed value — never off raw args:
const input = (() => {
if (typeof args !== 'string') return args ?? {} // already an object
const s = args.trim()
if (!s) return {}
try { return JSON.parse(s) } catch { return { raw: s } } // fall back to the raw string
})()
const topic = input.topic ?? '(none)' // read off `input`, not `args`
Schema-builder helper (rule 7). A tiny builder keeps every fan-in schema consistent, with
additionalProperties: false and a tight required[] (the FLOOR only — an over-broad required
makes StructuredOutput reject good work):
const obj = (properties, required = []) => ({
type: 'object', additionalProperties: false, required, properties,
})
const DIGEST = obj(
{ digest: { type: 'string' }, evidence: { type: 'array', items: { type: 'string' } } },
['digest', 'evidence'], // fields an agent can ALWAYS fill; keep verdict/artifact optional
)
const r = await agent(prompt, { label: 'lens', schema: DIGEST })
{ envelope: true } result shape. For a fan-in that must not lose a partial cohort, return a
typed envelope, not a bare array — it carries the survivors AND what failed, so a null-tolerant
barrier degrades visibly:
const results = (await parallel(thunks)).filter(Boolean) // rule 5: drop the nulls
return {
envelope: true,
ok: results.length,
total: thunks.length,
dropped: thunks.length - results.length, // how many agents died — surfaced, not hidden
results,
}
Companion artifacts in this workspace
/plan-gate(~/.claude/workflows/plan-gate.mjs) — saved, schema-typed Plan Review Gate: a mechanical stage-0 lint, then 3 fresh adversarial reviewers (Feasibility, Completeness, Scope & Alignment), each returning{ verdict, blocking }. SHADOW/advisory — records a verdict, never blocks./design-gate(~/.claude/workflows/design-gate.mjs) — saved Design Review Gate: 5 fresh reviewers (PM, Architect, Designer, Security, CTO) in parallel, each{ verdict, blocking }. SHADOW/advisory.plan_lint_stage0.py(~/.claude/harness-meta/) — the mechanical stage-0 the gate runs FIRST:lint(plan)checks WU ≤ 5 files, no overlapping scopes, and a valid DAG, returningPLAN_LINT:clean|dirty|na(SHADOW, exits 0). It also ships the cross-session resume manifest (done.jsonl+ write-aheadpartialrecords): mark a WUpartialbefore it runs anddoneafter it commits, then on a resumed sessionresume(all_wu_ids)classifies WUs into done / partial (crashed → re-drive) / pending — so a teammate that dies on a session cut is not silently redone. Wiring it is rule 13; the copy-paste template istemplates/resume-wired-fanout.md.
Both gates ship in SHADOW mode (council C5): they surface a verdict + append one line to a bounded, auto-rotating shadow sink; promotion to an ENFORCE gate that blocks on a blocking FAIL is a separate, deferred step.
Quick checklist before saving a workflow
- [ ] lint clean:
python skills/workflow-authoring/scripts/workflow_lint.py <file>(no ERROR findings) - [ ]
export const metawith a stablename+phases - [ ] a
schema(withadditionalProperties: false) on everyagent()fan-in - [ ]
.filter(Boolean)after everyparallel() - [ ]
isolation: 'worktree'on every file-MUTATING agent; disjoint scopes - [ ] no
Date.now()/Math.random()/new Date()in the workflow JS - [ ] byte-stable shared prompt prefix; volatile data only in per-agent tails
- [ ] one level of nesting; wide barriers kept small + homogeneous
- [ ] agents auto-discover their input (args arrive as one string)
- [ ] no spawn prompt asserts a tool is blocked without a live probe backing it
- [ ] every contracted output path is
Test-Path-checked at fan-in before its digest is read - [ ] cross-vendor units use
agentType: 'codex', never a*-rescueforwarder - [ ] briefed facts carry
[probe: …]+ a timestamp + a different-mechanism re-verify rule - [ ] adversarial reviewers receive siblings' FULL text and their own on-disk artifact contract
- [ ] agents flush a partial artifact on a cadence (a cohort interrupt must degrade, not zero)
04 同仓库更多 Skills
clarify-requirements
Resolve genuine pre-execution ambiguity by scanning repo context, generating 1-6 grounded options, and asking 1-3 focused questions via AskUserQuestion. NOT for mid-task use (injection risk); NOT when the goal is already unambiguous; use to disambiguate vague prompts before any file edits.
openmm-md
Run FREE, open-source, Windows-native GPU molecular dynamics via the openmm-md MCP (OpenMM + CUDA on the local NVIDIA GPU). Use for protein / protein-ligand MD when you want a free stack or are on Windows without a Schrodinger license. Covers the full workflow (prepare, solvate, minimize, equilibrate, production, analyze, mmgbsa) AND an honest decision guide vs Schrodinger Desmond, including how to run Desmond legitimately on Linux or a cloud GPU with a real license. This is a deliberately LESSER free alternative and does NOT replace the schrodinger MCP tools. Do NOT use it to pick a free tool when a validated FEP+, WaterMap, IFD-MD, or Prime MM-GBSA result is actually required.
visual-review
Take screenshots of web pages and UI using Playwright for visual review and iteration
status
Diagnostic status report — shows metaswarm installation state, project setup, and potential issues
start
Use when starting work on any task, when the user mentions metaswarm, or when the user wants to begin tracked development work
setup
Interactive project setup — detects your project, configures metaswarm, writes project-local files