Skip to content

Delegation — the registry, the ledger, and the loop breaker ​

How arbeitsplan reaches another plugin, how deep that may nest, and why the answer is a file rather than a convention.

Contents — two axes · the registry · the ledger · the loop breaker · worked chain

Delegation and ordering are different axes ​

They look alike and are not, and conflating them is how one of them ends up unenforced.

delegationordering
the questionwho dispatched whom, and how deephas the required step run
the shapea call grapha marker on disk
lives inthis ledger, gated by arbeitsplan's hook.claude/takt.local.md, gated by takt
covers a raw Write/Edit?no — there is no dispatch to recordyes

takt's canonical beat is "no *.tsx write until .takt/council-done exists". No delegation ledger can express it: nothing was dispatched. That is why generalizing delegation does not subsume takt, and why both mechanisms exist.

The registry ​

Every plugin declares what it offers and what it awaits, in plugins/<name>/.claude-plugin/beats.json:

json
{
  "$schema": "arbeitsplan/beats/1",
  "plugin": "andon",
  "produces": [
    { "marker": "wire-proven", "after": "andon-verify",
      "meaning": "one named wire has been proven against evidence, not asserted" }
  ],
  "requires": [
    { "marker": "transform-brief-written", "from": "befund", "before": "andon-loop",
      "optional": false, "reason": "andon-loop already says to STOP and run it first, in prose." }
  ]
}

emit_beats.py --repo-only compiles the union of every declaration into one .claude/takt.local.md, and takt enforces it. Two refusals in that compiler are deliberate:

  • an optional requirement whose producing plugin is absent is dropped and reported — enforcing an order against a plugin that cannot run would deny forever;
  • a requirement naming a marker no installed plugin produces is dangling: reported and not compiled, because a beat whose marker nothing can create is an unconditional denial wearing an ordering costume.

Why the file sits next to plugin.json ​

M-REF-UNWIRED requires every references/*.md to be named by a SKILL.md. takt ships no skills. A beats file under references/ would be permanently unwireable in the one plugin that most needs one, so it lives in .claude-plugin/ with the manifest, which is also where it belongs conceptually: it is plugin metadata, not documentation.

The ledger: analysis/arbeitsplan/<runId>/delegation.jsonl ​

One JSON object per line, append-only.

json
{"id":"d-0003","runId":"ap-2026-09-12-a3f1","parent":"d-0001","depth":2,"source":"arbeitsplan","target":"zirkel:zirkel-explore-branches","pattern":"parallel","branches":["b1","b2","b3"],"merge":{"at":"arbeitsplan","strategy":"first_success","combine":"select_winner"},"status":"in_progress","timestamp":"2026-09-12T09:10:00Z"}
keymeaning
id, parent, depthposition in the call graph; parent: null is a root
source, targetplugin, or plugin:skill. Compared by plugin — a cycle is a property of plugins, not of individual skills
patternserial (carries chain) or parallel (carries branches)
merge{at, strategy, combine} — wait_all/first_success/any × concat/select_winner/merge_fields. null for serial
statuspending, in_progress, completed, failed, denied

Why JSONL and not JSON ​

The ledger is written by agents running in parallel — the plugin's whole premise. A single JSON document needs read-modify-write: two writers read the same array, each appends its own record, and one silently disappears. An O_APPEND write of one line is atomic under PIPE_BUF, so parallel writers cannot lose each other's records. Same argument that made the dispatch ledger one-file-per-signature.

That guarantee has a size. POSIX floors PIPE_BUF at 512 bytes; Linux sets it at 4096. A longer record can interleave, so MAX_RECORD_BYTES is enforced on write and a long branch list belongs in the spec, not here.

A denied delegation is recorded before the refusal. A denial nobody can see afterwards is indistinguishable from a call that was never made, and the ledger is the only account of why a run stopped where it did.

The loop breaker ​

Two rules, both in hooks/arbeitsplan_guard.py, because prose is measured at baseline here.

rulecatcheswhy the other one misses it
depth cap: 3an honest but runaway chain, A→B→C→Dno cycle exists; nothing repeats
cycle detectionA→B→Adepth 2 — under any cap, so the cap would only decide how much it costs first

A design with only a cap treats a two-node infinite loop as legal until it has paid three levels of fan-out for it. A design with only cycle detection lets a genuinely deep chain multiply fan-out at every level. Neither subsumes the other, and the sabotage tests prove it: unbound the cap and only the DEPTH cases go red; disable the cycle check and only the CYCLE cases do.

A dispatch to this plugin's own agents is fan-out, not delegation, and is not counted — otherwise a three-candidate build would look like a three-deep chain and trip the breaker on ordinary work. The comparison is against the dispatching source, not the run's owner: using the owner left arbeitsplan → zirkel → arbeitsplan classified as fan-out, skipping the cycle check on exactly the shape it exists for. That hole was found by the calibration, not by reading.

A worked chain ​

d1  depth 0   arbeitsplan -> zirkel:zirkel-explore-branches   in_progress
d2  depth 1   zirkel     -> andon:andon-verify                 completed
d3  depth 2   andon       -> matrize:matrize-decode             completed
d4  depth 3   matrize     -> lehre:lehre-gauge                  DENIED — over the cap of 3

and a cycle, denied two levels earlier than the cap would have:

d1  depth 0   arbeitsplan -> zirkel:zirkel-solve              completed
d2  depth 1   zirkel     -> arbeitsplan:arbeitsplan-run        DENIED — arbeitsplan already
                                                                 appears in its own ancestor chain

Inspect either with:

bash
python3 plugins/arbeitsplan/scripts/delegation.py show --ledger analysis/arbeitsplan/<runId>/delegation.jsonl