§ 7 — Chores
7. Chores
Normative. This chapter defines the
choretop-level form: a thin specialization ofrecipefor command-runner-shaped bodies. The productions referenced here are given formally in App. A § A.3 and § A.4; where this chapter’s prose disagrees with App. A, App. A is authoritative (§ 2.5).
7.1. Chore header
A chore is introduced by a chore header of the form chore name (":" dependency_list)?, where name is either a BARE_IDENTIFIER or a double-quoted STRING (App. A.5). An optional : introduces a dependency list (semantics identical to § 6.2).
The keyword chore MUST be followed by a separator — whitespace, a tab, or a " character — before the name. The keyword is recognised only at column 0 (§ 3.4); an indented chore foo line inside a recipe body or chore body is a Content token dispatched as a shell_command.
The reserved-segment rule of § A.2 does NOT constrain a chore declaration name (CS-0132): chore name, chore out, and chore env are all well-formed, because a chore name is undotted (a dotted chore name is rejected outright as a dotted declaration name, § A.2). The reservation binds only dotted reference forms and the env-first prefix.
The duplicate-name rule of § A.2 also applies: chores share the callable namespace with recipes, so a chore whose name matches that of an earlier chore or recipe declaration in the same Cookfile MUST be rejected at parse time. The diagnostic identifies both the offending declaration and the source line of the prior declaration.
A chore body is terminated by the implicit-termination rule of § 4.3: it ranges from the line after the header to the next column-0 line classified as recipe, chore, config, use, or import, or end-of-file (whichever comes first).
Example 7.1.1
chore clean
rm -rf build
chore play: build
fzf < build/menu.txt
Two chores: clean (no deps), play (depends on the recipe build).
7.1.1. Parameters
A chore header MAY declare zero or more parameters between the chore name and the optional :. The full parameter grammar (including variadic and Lua-expression-default forms) is given in App. A.3.1; this subsection covers the required and defaulted-string forms.
A param is one of:
- Required parameter: a
BARE_IDENTIFIER. Must be supplied at invocation as a positional argument. - Defaulted parameter (string literal):
BARE_IDENTIFIER "=" STRING. If no argv is supplied at this position at invocation time, the literal string binds. - Defaulted parameter (Lua expression):
BARE_IDENTIFIER "=" "(" LUA_EXPR ")". If no argv is supplied at this position, the Lua expression is evaluated lazily against the Cookfile-scope Lua VM (§13.2 load phase). The result MUST be a string; a non-string return raises a diagnostic. The expression is evaluated only when argv is exhausted at this position — it is NOT evaluated when an explicit argv value is supplied. Note: Lua long-bracket strings ([[...]]) are NOT honoured inside the default expression in this version of the Standard; authors who need a long-bracket string inside a default MUST use a regular double-quoted string instead.
Ordering rule (normative). Within a param_list, every required parameter MUST precede every defaulted parameter (string-literal or Lua-expression form). A conforming implementation MUST reject a chore header that violates this order.
Parameter-name rule (normative). Parameter names MUST NOT contain .. Duplicate parameter names within one header MUST be rejected at parse time. The reserved-segment rule of §A.2 does NOT constrain parameter names (CS-0132): a parameter named name, in, out, or any other former reserved identifier is well-formed.
Example 7.1.1.1
chore deploy target host="prod.example.com"
> deploy.run(target, host)
deploy declares a required parameter target and a defaulted-string parameter host. Invoking cook deploy production binds target="production" and host="prod.example.com".
Introspection (informative). A declared param_list is part of a chore’s observable interface. A conforming implementation’s human-readable workspace listing (e.g. cook menu) SHOULD surface each chore’s declared parameters alongside its name — rendering a required parameter as its bare name, a defaulted-string parameter as NAME="default", a required variadic as NAME..., and an optional variadic as [NAME...] — so the invocation shape is discoverable without opening the Cookfile. The reference implementation spells this listing cook menu, with cook list as an alias for it; both render identically. This is a presentation affordance, not a language-surface rule.
7.1.2. Parameter binding inside the body
When a chore body is executed, every declared parameter binds as a Lua local in the body’s scope, visible to execute-Lua (>, >{}) and lua_block forms. The local’s value is a Lua string for required and defaulted parameters; for variadics (§7.1.3) it is a Lua sequence (list-style table) of strings.
A conforming implementation MUST make these bindings available before any step of the chore body (including the first shell or > line) is executed.
Placeholder substitution ($<NAME>) is specified in §7.1.2.2. Environment-variable export to shell children is specified in §7.1.2.1 below.
When parameters bind (normative). At chore-invocation time, before the chore body’s register phase runs:
- The implementation collects positional argv after the chore name. Implementation-defined argv-partitioning rules — for example, sigil-marked config-preset selection or
---style end-of-options separators — MAY remove tokens from this stream before binding. This Standard does not prescribe a CLI surface for those rules; the reference Cook CLI implements them per the conventions described in its CHANGELOG andcook --helpoutput, but a conforming implementation MAY choose other CLI surfaces. - argv is matched against the declared
param_listin source order. - For each required parameter: argv at this position MUST exist; a missing element is a diagnostic per the table below.
- For each defaulted parameter: if argv is exhausted at this position, the default is evaluated.
- Excess argv elements MUST raise a diagnostic per the table below (unless a variadic absorbs them, §7.1.3).
| Condition | Diagnostic (recommended form) |
|---|---|
| Required arg missing | chore 'NAME' requires parameter 'PARAM' (declared at line LINE); supply it as a positional argument |
| Excess argv with no variadic | chore 'NAME' takes K parameter(s) but M positional argument(s) were supplied |
| Recipe receives argv | recipe 'NAME': recipes do not take parameters; received N positional argument(s) (use '@PRESET' to select a config preset) |
| Lua-expression default raises a Lua error | chore 'NAME': default for parameter 'PARAM' raised a Lua error (defined at line LINE): MESSAGE |
| Lua-expression default returns non-coercible value | chore 'NAME': default for parameter 'PARAM' must evaluate to a string; got TYPE (defined at line LINE) |
Value fidelity through lowering (normative; CS-0209). A bound value is the argv element that supplied it, or the evaluated default, byte for byte. An implementation MAY lower a chore body into an intermediate program of its own design, and how it binds the value there is its own business, but that lowering MUST NOT alter, truncate, or reject a bound value on account of the characters it contains. A value containing a double quote, a backslash, a line terminator, or any other control character MUST bind as supplied — for a variadic, each element of it — and MUST arrive intact at the Lua local of this section, the environment export of § 7.1.2.1, and the placeholder expansion of § 7.1.2.2. Those last two specify transformations of their own, and they are the only ones this Standard permits: the context-aware shell quoting of § 7.1.2.2, at a $<NAME> placeholder in a shell step and nowhere else, and the space-joining by which a variadic becomes one environment-variable value (§ 7.1.2.1). Each applies to a value that has already bound intact; neither licenses altering it earlier.
This requirement is stated separately from § 3.1 because that section governs the text of a Cookfile, and an argv-supplied value never appears in one: a value can carry a byte the Cookfile grammar itself would never admit. A declared default is Cookfile text and is governed by both.
Coercion of Lua-expression default return (normative). Before the diagnostic above fires, a Lua-expression default’s return value MUST be coerced to a string under the following rules: a Lua string is used verbatim; a Lua number (integer or float) is converted via Lua tostring semantics (e.g. 42 → "42"); a Lua boolean is converted to "true" or "false". All other Lua types — including nil, table, function, thread, and userdata — MUST raise the non-coercible diagnostic.
7.1.2.1. Environment-variable export to shell children
A conforming implementation MUST export every declared parameter as an environment variable into every shell child process spawned by the chore body. The export MUST occur for all parameter kinds (required, defaulted-string, defaulted-Lua, and variadic).
Name mapping (normative). The environment-variable name is the parameter name verbatim — no case normalisation or other transformation is applied.
Value mapping (normative).
- For required and defaulted parameters: the environment-variable value is the bound string value.
- For variadic parameters (§7.1.3): the environment-variable value is the space-joined string form of the bound list (no per-element quoting is applied, since environment variables are flat strings). For per-element access, use the Lua local (which is a sequence table) or the
$<NAME>placeholder (which expands each element as a separately shell-quoted word).
Empty variadic (*NAME with zero argv). A *NAME variadic that binds the empty list MUST still be exported; the value is the empty string.
Scope (normative). The export applies to all shell steps in the chore body. The environment variable is visible to any sub-process spawned by the shell child (it is inherited through the normal POSIX environment inheritance mechanism).
7.1.2.2. Placeholder substitution in chore bodies
In a chore shell step, a $<NAME> placeholder resolves in this order:
- If
NAMEis a parameter declared by the chore header, the placeholder expands to the bound value quoted for the shell context in which the placeholder appears (CS-0128): in an unquoted region it expands to a single POSIX-shell-quoted word; inside a double-quoted region it expands to the bound value escaped for double-quote context (backslash-escaping\,",$, and the backtick) so the value sits literally inside the author’s quotes (echo "hello $<who>"withwho="world"yieldshello world); inside a single-quoted region it expands verbatim (the author’s'...'supplies the literal region — a value containing'is the author’s responsibility). For a variadic parameter, each bound element is quoted the same context-aware way and the elements are joined by a single space. - Otherwise, the placeholder follows the ordinary closed-set placeholder resolution of §9 exactly as in a recipe body. Recipe references therefore resolve per §10.2 and establish the dependency edge required by §10.6; declared-variable references resolve through the
var.NAMEnamespace (§ 10.7).
Parameters shadow recipe names and environment names. Resolution MUST NOT depend on whether the chore declares parameters: a $<MODE> env reference and a $<build> recipe reference behave the same in parameterless and parametric chores unless MODE or build is also a declared parameter.
7.1.3. Variadic parameters
A chore MAY declare at most one variadic parameter at the tail of the param_list:
+NAME— one-or-more variadic. At invocation, all remaining argv collect into a Lua sequence (list-style table); if zero remain, a diagnostic is raised.*NAME— zero-or-more variadic. At invocation, all remaining argv collect into a Lua sequence; if zero remain, the local binds to{}.
Tail rule (normative). A variadic parameter MUST be the final parameter in the param_list. A conforming implementation MUST reject a header with a non-variadic parameter after a variadic.
Single-variadic rule (normative). At most one variadic parameter is permitted per header.
No-default rule (normative). A variadic parameter MUST NOT carry an =default. The *NAME form is the way to express “optional variadic”.
| Condition | Diagnostic (recommended form) |
|---|---|
+NAME with zero argv | chore 'NAME' requires one or more values for variadic '+PARAM' (declared at line LINE); supply at least one, or change to '*PARAM' to allow zero |
7.2. Chore body — allowed and banned step kinds
A chore body MAY contain any of the following step kinds:
| § | Step kind | Phase |
|---|---|---|
| § 8.8 | shell_command | execute (default-interactive, single-drain) |
| § 8.7 | lua_line | execute |
| § 8.7 | lua_block | execute |
A chore body MUST NOT contain any of the following step kinds:
| § | Step kind | Diagnostic guidance |
|---|---|---|
| § 8.2 | gather_step | The diagnostic identifies the keyword and directs the author to recipe. |
| § 8.4 | cook_step | The diagnostic identifies the keyword and directs the author to recipe. |
| § 8.6 | test_step | The diagnostic identifies the keyword and directs the author to recipe. |
A conforming implementation MUST reject a chore body that contains any of the three banned step kinds. The diagnostic MUST identify the offending step’s line and the offending keyword by name; the recommended message form names the keyword and suggests the recipe form.
No register-phase steps (CS-0134). CS-0134 removes the register-phase inline_lua_line (>>) and inline_lua_block (>>{) steps from the language entirely (§ 3.7, Note 3.7.1). A chore body therefore admits only the four execute-phase step kinds above. Because there is no declarative-region step kind left in a chore body, the pre-CS-0134 region-ordering rule (§ 6.3) is satisfied vacuously for chore bodies — there is no declarative region to place before the imperative steps. A conforming implementation MUST reject a >> or >>{ line wherever it appears, including inside a chore body, with the migration diagnostic of § 3.10.
Migration note (CS-0134). Prior to CS-0072 a chore body admitted a module_call step (register-phase). CS-0072 removed it, and pre-CS-0134 authors reached for the >> / >>{ inline-Lua prefixes to invoke a module function from a chore body. Both prefixes are now gone. An author who needs register-phase module-call orchestration MUST move it to a top-level register_block (§ 5.4) or a top-level module_call (§ 4.4); an author who wants to call a module function at execute time from a chore body writes it as an execute-Lua step — > ns.fn(...) or inside a >{ … } block — which runs on the chore’s drain thread (§ 7.3).
7.2.1. Classifying a keyword-initial chore line (CS-0233)
The ban above is on the three step kinds, not on the three words. A chore body is the shell-first form (§ 7.3), and gather, cook and test are all ordinary words at the start of an everyday shell command — test -f src/canvas.ts and cook build are among the most common lines an author writes in a chore. A conforming implementation MUST NOT reject a chore-body Content line merely because its first word is one of the three keywords.
Classification rule (normative). A Content token in a chore_body whose first word is gather, cook or test followed by a separator (space, tab, or end of token) is the corresponding banned step kind — and MUST be rejected per the rule above — when the remainder of the line, after leading whitespace, opens with that step kind’s own syntax:
| Keyword | Is the banned step kind when the remainder opens with | Otherwise |
|---|---|---|
gather | " or ' (a glob pattern), or ! (an exclude input, App. A.4), or is a single bare identifier and nothing else (a named member source) — § 8.2 | shell_command |
cook | " or ' (an output pattern, § 8.4, § 8.5), or ( (the Lua-expression output form, § 8.4.2) | shell_command |
test | { or >{ (a body, § 8.6) | shell_command |
The test is on the opening of the remainder, not on the whole line parsing as that step, and it errs toward the ban in two places. A '-quoted operand is treated as a gather / cook shape although '…' is not a STRING (App. A.4) and such a line is rejected rather than run; likewise gather !x, where ! opens no valid input. Both are near-misses of a step spelling rather than commands anyone writes, and a diagnostic naming the keyword serves the author better than a shell error would.
A quoted remainder is a gather or cook shape only. It is deliberately not a test shape: a test step’s sole operand is its body, and the bare-string form test "cmd" is not a test_step in any position (§ 8.6), so test "$X" = y is a shell_command. A bare keyword with no remainder at all (test alone on a line) carries no step syntax and is likewise a shell_command.
The gather exclude row matters more than its rarity suggests. gather is also a real program name, so a misclassified gather !"build/*" would parse cleanly and then fail at run time with gather: command not found — an error about the wrong thing, raised after the chore has already begun. It is the only line this rule could turn from a parse error into a wrong meaning rather than into working shell.
This rule governs classification only. It does not relax the ban: a line that does match a banned step kind’s syntax MUST still be rejected with the diagnostic above. It has no effect on a recipe_body, where the step-dispatch cascade of § 8.1 applies unchanged and a loose shell command is rejected outright.
Example 7.2.1
chore guard
test -f src/canvas.ts
[ -f src/canvas.ts ]
cook build
All three lines are shell_command steps. The first begins with the keyword test, but its remainder (-f src/canvas.ts) is not a body, so it is not a test_step; the third invokes the cook binary itself, and its remainder (build) is not an output pattern.
7.3. Default-interactive shell semantics
Every shell_command step inside a chore body is interactive: it runs with the controlling terminal inherited, so the spawned child sees a real TTY (§ 8.8). This is the chore default; there is no marker to write. CS-0134 removed the @ interactive prefix from the language entirely (§ 3.7) — it was a redundant no-op, since every chore shell command is already interactive by this rule. A chore therefore inherits the TTY-allocation and stdio-inheritance semantics that the drain rule (§ 16.2) specifies for an interactive unit.
One invocation per step (CS-0193). Each shell_command step in a chore body denotes exactly one shell invocation: the step’s text passed to a single /bin/sh -c. Steps are never composed — the block rule of § 8.3.1 is scoped to { … } bodies and does not reach a chore body, whose steps are separate invocations by construction. Three consequences are normative:
- A failing step fails the chore. Steps after it MUST NOT run, and the chore’s dependents observe the failure under the general unit-failure semantics of § 13.1.
- Process state does not persist between steps. Each step spawns at the chore’s working directory in a fresh shell, so a
cd, a shell variable, or atrapon one line does not survive to the next. An author who needs persistence writes the sequence as one step (cd build && rsync …). Before CS-0193 the non-persistence was true but unstated, and Example 7.3.1 read as if it were otherwise. - Each step is one unit of progress. An implementation SHOULD report per-step progress (step k of n) for a chore body.
Probe-value references (CS-0193). A $<key:…> probe-value reference in a chore shell_command step follows § 22.5.7 exactly as in a cook body: the reference MUST reach register-time capture as literal $<…> text in the step’s command string, MUST union its key into the step’s probes — gaining the DAG edge and demand-driven scheduling of § 22.5.6 — and MUST be substituted per § 22.5.7‘s rules, including the composite-value diagnostics, before the interactive command begins. (Prior to CS-0193 a chore probe reference lowered to a register-time cook.probes.get read, which raised outside module context and created no edge; the surface was unusable, so this is a definition, not a behaviour change.)
Interactive output belongs to the terminal (CS-0193). An interactive step’s stdin, stdout, and stderr are the controlling terminal’s (§ 16.2); its output is therefore not captured, not attributed to the unit, and records no observation. This is a stated exemption from the output-ownership rule of CS-0188 and the observation recording of CS-0189, not an accident of implementation: what an interactive session prints belongs to the session, and capturing it would break the TTY property this section promises (a full-screen tool like fzf owns the terminal while it runs). Execute-Lua steps in a chore body reach the same destination by the drain-designation rule above (CS-0194): the terminal owns a chore body’s output in its entirety.
The execute-Lua step phases (> / >{) are unchanged by this rule. An execute-phase Lua step inside a chore body runs on the chore’s drain thread — and the drain thread is a role, not a fixed thread (CS-0194). During a chore window an implementation designates one execution context as the drain; a worker VM so designated IS the drain thread for the window’s duration, which the single-drain rule below makes unambiguous (only one chore body ever runs). What the role requires is observable, not structural: the body’s steps run serially in source order with nothing else in flight, and the body’s output — a Lua step’s print/io.write and both streams of its cook.sh calls alike — reaches the controlling terminal directly, in call order, with cook.sh’s return value unchanged (§ 24.1). Before CS-0194 this section prescribed the mechanism (“not on a worker VM”) rather than the property, and the reference implementation’s worker-run Lua steps split their output: print reached the terminal while cook.sh streams detoured through the capture sink and a progress channel, arriving out of order on a different descriptor. The body-bundling rule (§ 15.2) does NOT apply to chore bodies — as of CS-0134 it is vacuous even for recipes (§ 15.2); chore-body execution is governed entirely by the single-drain rule of this section.
A chore body MUST execute as a single drain (CS-0051): once the first imperative-region step of the body begins, no work unit of any other recipe MAY run until the body completes — successfully or with a failure. The body’s shell steps and execute-phase Lua steps run in source order on the controlling terminal under one shared drain window. The interactive-drain rule of § 16.2 governs each individual interactive unit; the chore-body case additionally shares a single drain across all of a chore’s imperative steps (its shell and execute-Lua steps), rather than draining them one at a time.
Example 7.3.1
chore deploy
cd build && rsync -av out/ user@host:/srv/app/
> print("deploy complete")
The shell step is interactive: it runs with the user’s TTY inherited. The cd and the rsync share one step deliberately — steps are separate invocations, so a cd on its own line would not survive to the next (CS-0193). The > line runs at execute phase on the drain thread (controlling terminal), not on a worker VM (CS-0051). There is no body-bundling, and the shell step and the > Lua line execute under one shared drain — no work unit of any other recipe interleaves with them (CS-0051).
7.4. No caching
A chore body MUST NOT register a cached work unit. This is satisfied trivially by the parse-time ban on cook and test steps (§ 7.2); a chore has no declarative cook step that would produce a cacheable output. As of CS-0134 a chore body admits no register-phase step at all (§ 7.2), so it cannot record a work unit from its own body: the register-only helper cook.add_unit (§ 22.1) is unreachable from a chore body, and calling it from an execute-Lua step (> / >{) raises the execute-phase diagnostic of § C.6.12 regardless of the cache flag.
This rule preserves the chore-vs-recipe semantic distinction: chores always run when invoked; they never short-circuit on a cache hit.
Note 7.4.1
A chore is not a separate mechanism: it compiles to a recipe-body Lua function whose registered units are all cache = false and whose shell steps default to interactive = true, so registration, dependency edges, probe consumption (CS-0193), and failure propagation are the ordinary unit machinery. What IS chore-shaped in an implementation is scheduling policy: the drain rule of § 16.2 requires interactive units to wait out concurrent work and hold the terminal, which the reference implementation serves with a dedicated execute-phase window. (This note previously claimed the engine needs no chore-specific code path; the drain rule is exactly such a path, and pretending otherwise hid it — CS-0193.)
7.5. Cross-form dependencies
A chore MAY depend on a recipe (run a build, then post-process) and a recipe MAY depend on a chore (rare but legal — for example, a recipe that depends on a one-off database-seed chore). Dependency resolution and ordering follow the rules of § 10; the form of the depender and depended-upon does not affect the resolution algorithm.
Example 7.5.1
recipe build
gather "src/*.c"
cook "build/$<in.stem>.o" { cc -c $<in> -o $<out> }
cook "build/app" { cc $<in> -o $<out> }
chore play: build
./build/app
recipe install: play
cook "/usr/local/bin/app" { cp build/app $<out> }
play depends on the recipe build; the recipe install depends on the chore play. The graph: build (recipe, cached) → play (chore, never cached) → install (recipe, cached). Each edge is a normal name reference; no special wording for cross-form.
7.5.1. Dependencies on parametric chores
A recipe or chore MAY depend on a parametric chore (§7.1.1). The dependency edge supplies no argv, so the depended-upon chore runs as if invoked with no positional arguments: defaulted parameters bind their defaults (§7.1.1), zero-or-more variadics (*NAME) bind to the empty sequence, and any required parameter — or one-or-more variadic (+NAME) — without a satisfying argv is a configuration error rejected at register time with the same diagnostic class as the invocation-time errors of §7.1.2.
Supplying explicit argv from a dependency edge (e.g. recipe X: (helper foo bar)) is reserved for a future revision and is not part of this Standard.
Note 7.5.1.1 — Scope of register-time parameter checks (informative)
The register-time validation described above applies only to parametric chores reachable from the dispatch target via the requires graph (§ 10) — i.e. the target itself plus its transitive dependencies. A parametric chore that declares a required parameter (or +NAME variadic) and is not in that reachable set is not validated at register time during the current dispatch: its body is not invoked (§ 7.6) and its parameters are not bound. This keeps unrelated chores in the same Cookfile from poisoning every other invocation; declaring chore deploy target does not block cook greet alice from running.
Implementations MAY still register, list, and diagnose syntactic problems with unreachable parametric chores (so that cook list, cook menu, and cook why surface them); only the bind-argv-to-parameters step is scoped to the reachable set.
7.6. When a chore body is invoked
Every register pass has a set of targets: what the invocation asked for. A pass dispatching cook NAME has one; a pass that only reads the graph, such as one answering which recipes an edit affects, has none; a pass on behalf of a command that operates over the whole workspace, such as running every test or watching every input, has every recipe.
A chore’s body is invoked during a register pass if and only if the chore is one of that pass’s targets, or is reachable from one through the requires graph (§ 10), whether the edge was declared statically or established at register time by cook.require_recipe (§ 22.9). Any other invocation of a chore body is speculative, and a conforming implementation MUST NOT perform one.
The rule is stated over reachability and nothing else. In particular an implementation MUST NOT make it depend on whether the chore declares parameters (§ 7.1.1): a chore that declares none is not thereby safe to run unasked. An implementation MUST NOT take a whole-workspace pass’s target set to be every name: a chore is a command, not a build target, so the set is the recipes, and a chore no recipe reaches — a module’s project-management verb, say — is reachable from nothing and is not invoked. A pass with no targets invokes no chore body at all.
The graph is the workspace’s, not the Cookfile’s. Reachability is computed over the composed workspace (§ 11), across import boundaries, exactly as the build closure is. Where an implementation registers a workspace one Cookfile at a time, no single pass holds that graph: chore a: sub.b is an edge the importer declares and the importee’s own pass cannot see, and the importee’s pass is the one deciding whether b’s body runs. A conforming implementation MUST resolve reachability before that decision is taken rather than per Cookfile. Deciding locally makes a chore that IS in the build closure register no units, which the target then depends on and never gets — a register/execute disagreement, and a silent one: the build reports success having done part of what it was asked.
A speculatively-skipped chore remains registered: it is listed by discovery surfaces (§ 22.7), it is invocable by name, and its declared requires still participate in the graph. What is skipped is the body, and only for the pass in which nothing reached it. An implementation MUST NOT report the skip as a failure, and MUST re-invoke a skipped body if a later cook.require_recipe in the same pass brings the chore into the reachable set — registering zero units for a chore that IS in the build closure is the register/execute disagreement § 22.9 declares expressly non-conforming.
Note 7.6.1 — why this is a rule and not an optimisation (informative)
Nothing observable is lost by skipping. A chore is never cached and does not participate in the build DAG (§ 7.4, § 22.12), so units captured from a chore that is not in the build closure are discarded by the planner; and a discovery surface needs the chore’s registration, which happens before any body runs, not its units.
What is gained is that a chore body cannot act unasked. Since § 22.13 a chore body can rewrite the author’s Cookfile, and § 22.12 exists precisely so a module can offer project-management verbs — scaffold a target, add a dependency, reformat sources. A verb of that kind whose body ran on every register pass would splice, scaffold or reformat during a build of something unrelated, during a watch loop, and during whatever an editor runs on each keystroke. That the author never invoked it is not a mitigation: it is the whole complaint.
The narrower reading — permit the body but refuse the surfaces that write — was considered and rejected. A refusal can only bind the surfaces this Standard defines, while the embedded language reaches its own file and process facilities; and a refusal loud enough to be honest would make one module’s verb fail every unrelated build in the project, which is a worse outcome than the one it fixes. Declining to invoke a body nobody asked for is the form of this rule that is a guarantee rather than a fence. See § C.26.