entropy-machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
package/bin/dispatch ADDED
@@ -0,0 +1,700 @@
1
+ #!/bin/sh
2
+ # Pre-flight gate for dispatching a writing agent.
3
+ #
4
+ # bin/dispatch i-foo --files "src/x.ts tests/x.test.ts" --brief "one-line task"
5
+ #
6
+ # Exit 0 means SAFE TO DISPATCH; anything else names the hazard. It does not
7
+ # spawn the agent — your agent runner does that — it refuses the mistakes
8
+ # that have each burned a real run:
9
+ #
10
+ # 1. NON-ROOT CWD. A worktree is created in whatever repo the dispatching
11
+ # session's cwd belongs to. A session parked one directory off from the
12
+ # repo root has, more than once, handed an agent a worktree of the
13
+ # WRONG repository — none of the files it needed were in it. This
14
+ # checks the caller's cwd before anything else.
15
+ # 2. UNCOMMITTED WORK INSIDE THE AGENT'S OWN FILE SCOPE. A worker's
16
+ # worktree branches from local HEAD, so unpushed commits are visible —
17
+ # nothing has to be pushed before dispatching. What is still invisible
18
+ # is uncommitted edits: hand an agent a file you are mid-edit in and it
19
+ # works from the committed version, and its result silently drops your
20
+ # changes. Only the declared scope is checked; a dirty tree elsewhere is
21
+ # none of this agent's business.
22
+ # 3. ALREADY-LANDED WORK. If git log already mentions the issue id, the
23
+ # task may be partly or fully done. The audit comes before the dispatch.
24
+ # 4. UNRECORDED BRIEFS. The brief + file scope are appended to the tracker
25
+ # notes log in the same act, so any session can see what was handed out
26
+ # and scopes can be checked for overlap before agents collide in a file.
27
+ #
28
+ # THE SCOPE LIST IS A DENYLIST, NOT AN ALLOWLIST. --files used to declare the
29
+ # files the agent MAY write: a prediction made at the moment of least
30
+ # information, by the one party who has not read the code, and correcting it
31
+ # later costs a round trip. The dispatcher does not know what the agent will
32
+ # need. It knows one thing that is true and small: which files OTHER agents
33
+ # are holding right now, because it dispatched them. So the ENFORCED, PASTED
34
+ # thing is the exclusion set, and --files stays an advisory prediction that
35
+ # is still recorded for the audit trail.
36
+ #
37
+ # Every recorded scope failure under the old allowlist model was an
38
+ # exclusion with nothing behind it: a shared, append-only interface file
39
+ # left out of scope, so a change to it shipped undeclared; a wire-format
40
+ # producer left out of scope because its directory wasn't listed, so a field
41
+ # shipped with no writer; a worker's own brand-new changelog fragment
42
+ # refused at landing purely because the scope line had been drawn too
43
+ # narrow to get past a gate.
44
+ #
45
+ # THE CLAIM SET NEEDS NO NEW STATE. This script writes a DISPATCH note to the
46
+ # tracker and bin/handoff writes the matching HANDOFF note that releases it,
47
+ # so live claims are derivable from the notes log: an issue whose last event
48
+ # is a DISPATCH holds its scope, a HANDOFF releases it, and a claim by the id
49
+ # being dispatched now is not a claim against this agent. An unreachable
50
+ # tracker degrades to "unavailable" and the context file tells the agent to
51
+ # treat the advisory list as hard — an unreadable claim log is not an empty
52
+ # one.
53
+ #
54
+ # COLLISION DETECTION STAYS AT THE LAND STEP. `bin/handoff --lift` compares
55
+ # what the worker changed against the recorded denylist and refuses a
56
+ # claimed file just as hard as an out-of-allowlist one used to be refused; a
57
+ # file outside --files that nobody claimed is reported for the lander to
58
+ # read, not refused.
59
+ #
60
+ # IT ALSO WRITES THE AGENT'S CONTEXT FILE. Everything this script already
61
+ # knows was, for a long time, printed only to the DISPATCHER's terminal and
62
+ # reached the agent only if a human retyped it. Measured over 224 subagent
63
+ # transcripts / 152 worktree agents on an earlier version of this workflow: a
64
+ # rule PASTED into the brief was followed ~96% of the time; the same rule
65
+ # sitting only in a file the agent was told to go read was followed ~38%;
66
+ # left unmentioned, ~8%. Compliance tracks what the brief PASTES, not what it
67
+ # points at. So the issue text, the carry-forward notes, `git log --grep`,
68
+ # the scope and the brief are all WRITTEN to a file, and the brief block's
69
+ # first line is the absolute path to it.
70
+ #
71
+ # WHY A WORKTREE'S BASE IS LOCAL HEAD, AND WHY THAT NEEDS CHECK 1. Basing a
72
+ # worktree on the pushed remote instead requires a push before every
73
+ # dispatch, which serialises landing across concurrent sessions for no
74
+ # safety benefit — agents are isolated by their worktree, not by their base
75
+ # commit. Basing it on local HEAD instead means a worktree is created in
76
+ # whatever repo the dispatching shell's cwd resolves to, so a session that
77
+ # has drifted into a different directory gets a worktree of the WRONG repo
78
+ # with no obvious symptom until the agent can't find its own files. Check 1
79
+ # is what closes that.
80
+ #
81
+ # --anyway skips check 3 only (the git-log hits are still printed); checks 1
82
+ # and 2 have no override — there is no correct dispatch from a wrong cwd or
83
+ # over your own uncommitted edits.
84
+ set -e
85
+
86
+ usage() { echo "usage: bin/dispatch <issue-id> --files \"<paths>\" --brief \"<text>\" [--anyway] [--dry-run]" >&2; exit 2; }
87
+
88
+ id=""; files=""; brief=""; anyway=0; dry=0
89
+ while [ $# -gt 0 ]; do
90
+ case "$1" in
91
+ --files) files="$2"; shift 2 ;;
92
+ --brief) brief="$2"; shift 2 ;;
93
+ --anyway) anyway=1; shift ;;
94
+ # Run every check and print the brief block, but write nothing to the
95
+ # log. Added after a smoke test of this script left a phantom DISPATCH
96
+ # note behind — a note that can be annotated but never removed, and that
97
+ # reads to any later session as an agent someone forgot to land.
98
+ --dry-run) dry=1; shift ;;
99
+ -*) usage ;;
100
+ *) [ -n "$id" ] && usage; id="$1"; shift ;;
101
+ esac
102
+ done
103
+ [ -n "$id" ] && [ -n "$files" ] && [ -n "$brief" ] || usage
104
+
105
+ . "$(dirname "$0")/../lib/roots.sh"
106
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
107
+ entropy_machines_require_root dispatch
108
+ CONFIG_PY="$ENTROPY_MACHINES_HOME/lib/config.py"
109
+ NOTES_PY="$ENTROPY_MACHINES_HOME/lib/notes.py"
110
+ LIBDIR="$ENTROPY_MACHINES_HOME/lib"
111
+ : "${ENTROPY_ACTOR:=dispatch}"
112
+ export ENTROPY_ACTOR
113
+
114
+ cfg_get() { python3 "$CONFIG_PY" get "$1" 2>/dev/null; }
115
+
116
+ if [ "$(pwd -P)" != "$ENTROPY_MACHINES_ROOT" ]; then
117
+ echo "dispatch: REFUSED — cwd is $(pwd -P), not the project root ($ENTROPY_MACHINES_ROOT)." >&2
118
+ echo " Worktrees are created in the repo the dispatching session's cwd is in." >&2
119
+ echo " Run: cd $ENTROPY_MACHINES_ROOT" >&2
120
+ exit 1
121
+ fi
122
+
123
+ # The handoff gate is a commit-msg hook and nothing else — see
124
+ # lib/handoff-guard.sh for why it is local-only by design. That makes an
125
+ # uninstalled hook a silent total loss of the gate, in a checkout that looks
126
+ # identical to one that has it. Checked HERE because dispatch is the last
127
+ # moment before the only activity the gate protects.
128
+ #
129
+ # BOTH halves are checked: the installed shim, AND the checked-in hook it
130
+ # delegates to. The hooks directory is SHARED by every worktree of this
131
+ # repo, but the checked-in hook file it execs is per-worktree — so a
132
+ # worktree whose checkout predates the hook has the shim, has a dead gate,
133
+ # and checking only the shim would report OK.
134
+ hooks=$(git rev-parse --git-path hooks)
135
+ case "$hooks" in /*) ;; *) hooks="$ENTROPY_MACHINES_ROOT/$hooks" ;; esac
136
+ if [ ! -x "$hooks/commit-msg" ]; then
137
+ echo "dispatch: REFUSED — no commit-msg shim in $hooks." >&2
138
+ echo " Without it nothing requires a handoff when you land this agent, and the" >&2
139
+ echo " only record of what it found and did not fix dies with its worktree." >&2
140
+ echo " Run: $ENTROPY_MACHINES_HOME/lib/install-hooks.sh" >&2
141
+ exit 1
142
+ fi
143
+ if [ ! -x "$ENTROPY_MACHINES_HOME/hooks/commit-msg" ]; then
144
+ echo "dispatch: REFUSED — the commit-msg shim is installed but the harness has" >&2
145
+ echo " no hooks/commit-msg, so the shim exits 0 and the gate is dead." >&2
146
+ echo " $ENTROPY_MACHINES_HOME is on a commit older than the hook." >&2
147
+ echo " Merge or rebase the harness onto a commit that has it, or land this work" >&2
148
+ echo " from a harness checkout that does." >&2
149
+ exit 1
150
+ fi
151
+
152
+ branch=$(git branch --show-current)
153
+ head=$(git rev-parse --short HEAD)
154
+ echo "dispatch: agent will branch from $branch @ $head (base: local HEAD)."
155
+
156
+ # Uncommitted edits are invisible inside the worktree. Only a collision with
157
+ # the agent's OWN declared scope is a hazard, so that is all this refuses on
158
+ # — a dirty tree elsewhere is normal and none of this agent's business.
159
+ #
160
+ # Scope entries carry annotations ("path/(own)", "dir/*.md", "dir/") and the
161
+ # matching logic that understands them lives in lib/notes.py — path_under()
162
+ # — so this and every other scope check in this file and bin/handoff read
163
+ # one annotation grammar the same way, instead of five independent sed
164
+ # reimplementations of it.
165
+ dirty=$(git status --porcelain 2>/dev/null | awk '{print $NF}')
166
+ clash=$(python3 -c '
167
+ import sys
168
+ sys.path.insert(0, sys.argv[1])
169
+ import notes as notes_mod
170
+ scope = sys.argv[2].split()
171
+ dirty = sys.argv[3].split()
172
+ print(" ".join(d for d in dirty if notes_mod.path_under(d, scope)))
173
+ ' "$LIBDIR" "$files" "$dirty")
174
+ if [ -n "$clash" ]; then
175
+ echo "dispatch: REFUSED — you have uncommitted edits inside the agent's own file scope:" >&2
176
+ for c in $clash; do echo " $c" >&2; done
177
+ echo " The agent inherits committed HEAD, so it would work from the old version" >&2
178
+ echo " and its result would silently drop these edits. Commit them first." >&2
179
+ exit 1
180
+ fi
181
+
182
+ hits=$(git log --oneline --grep="$id" | head -5)
183
+ if [ -n "$hits" ]; then
184
+ echo "dispatch: git log already mentions $id:" >&2
185
+ echo "$hits" >&2
186
+ if [ "$anyway" != "1" ]; then
187
+ echo " Audit what landed before briefing (bin/tracker show $id), then rerun with --anyway." >&2
188
+ exit 1
189
+ fi
190
+ fi
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # THE DENYLIST — what OTHER agents are holding right now
194
+ # ---------------------------------------------------------------------------
195
+ # Derived from the shared notes log, which already carries both halves: this
196
+ # script writes a DISPATCH note and bin/handoff writes a HANDOFF note that
197
+ # releases it. Last event per id wins, so a re-dispatched issue reclaims its
198
+ # files and a handed-off one releases them. No new state, no registry file.
199
+ #
200
+ # A CLAIM EXPIRES after DISPATCH_CLAIM_HOURS (default 24). The notes log is
201
+ # append-only with no delete, so a dispatch that was never handed off would
202
+ # otherwise hold its files forever, and the end state of an ever-growing
203
+ # denylist is the allowlist this inversion exists to avoid, with worse
204
+ # ergonomics. The changelog fragment directory, if configured, is never
205
+ # claimable — every agent writes its own file there by design, so a claim on
206
+ # the directory would wall every OTHER agent off its own fragment.
207
+ #
208
+ # THREE STATES, and they are not the same. A real list is enforced at lift.
209
+ # An empty list ("(none)") means the log was read and nobody holds anything,
210
+ # so every file is the agent's. "(unavailable)" means the log could not be
211
+ # read at all, and that degrades to the OLD allowlist behaviour on both
212
+ # ends: the context file tells the agent to treat --files as hard, and
213
+ # bin/handoff falls back to refusing anything outside it. An unreadable
214
+ # claim log is not an empty one.
215
+ never_claimed=""
216
+ if [ "$(cfg_get changelog.enabled)" = "true" ]; then
217
+ never_claimed=$(cfg_get changelog.fragmentDir)
218
+ fi
219
+
220
+ claims=""; deny=""; claim_lines=""; deny_ok=0
221
+ if claims_raw=$("$ENTROPY_MACHINES_HOME/bin/tracker" notes 2>/dev/null); then
222
+ deny_ok=1
223
+ hours="${DISPATCH_CLAIM_HOURS:-24}"
224
+ if [ -n "$never_claimed" ]; then
225
+ claims=$(printf '%s\n' "$claims_raw" | python3 "$NOTES_PY" claims --self "$id" --hours "$hours" --exclude "$never_claimed" 2>/dev/null || true)
226
+ else
227
+ claims=$(printf '%s\n' "$claims_raw" | python3 "$NOTES_PY" claims --self "$id" --hours "$hours" 2>/dev/null || true)
228
+ fi
229
+ deny=$(printf '%s\n' "$claims" | awk -F'\t' '$1=="PATHS"{print $2}')
230
+ # One line per claiming issue, so the agent can see WHO holds a file and
231
+ # the lander can go ask them. A flat path list alone reads as an
232
+ # arbitrary wall.
233
+ claim_lines=$(printf '%s\n' "$claims" | awk -F'\t' '$1=="CLAIM"{printf " %s\n (claimed by %s)\n", $3, $2}')
234
+ fi
235
+
236
+ if [ "$deny_ok" -eq 0 ]; then deny_field="(unavailable)"
237
+ elif [ -z "$deny" ]; then deny_field="(none)"
238
+ else deny_field="$deny"; fi
239
+
240
+ # --files overlapping a live claim is dispatch rule 3 — two issues in one
241
+ # file serialize, they do not run concurrently. Printed, not refused:
242
+ # re-dispatching an issue over its own predecessor's files is legitimate,
243
+ # and the lander is the one who can tell that from a genuine collision. The
244
+ # lift-side refusal is the mechanism; this is the early warning.
245
+ overlap=$(python3 -c '
246
+ import sys
247
+ sys.path.insert(0, sys.argv[1])
248
+ import notes as notes_mod
249
+ files = sys.argv[2].split()
250
+ deny = sys.argv[3].split()
251
+ seen, out = set(), []
252
+ for d in deny:
253
+ if d in seen:
254
+ continue
255
+ if any(notes_mod.paths_overlap(f, d) for f in files):
256
+ out.append(d); seen.add(d)
257
+ print(" ".join(out))
258
+ ' "$LIBDIR" "$files" "$deny")
259
+ if [ -n "$overlap" ]; then
260
+ echo "dispatch: WARNING — your --files overlaps a LIVE claim by another issue:" >&2
261
+ for o in $overlap; do echo " $o" >&2; done
262
+ echo " Two issues touching one file serialize; they do not run concurrently." >&2
263
+ echo " Land or hand off the other agent first, or narrow this one." >&2
264
+ echo " bin/handoff --lift will refuse these files when this agent returns." >&2
265
+ fi
266
+
267
+ # THE INTERROGATION FIELD. A note carrying it is an agent that was actually
268
+ # told, in its own brief, that it would be questioned before its work was
269
+ # lifted — that is what lets bin/handoff enforce a contract the agent was
270
+ # actually given, rather than one invented at landing time. Notes without it
271
+ # — hand-done work, or a note written before this landed — are reported and
272
+ # never refused.
273
+ interrogation_field="required"
274
+
275
+ if [ "$dry" -eq 1 ]; then
276
+ echo "dispatch: --dry-run — all checks passed, nothing recorded. Would log a"
277
+ echo " DISPATCH note: scope=$files brief=$brief interrogation=$interrogation_field denylist=$deny_field"
278
+ else
279
+ if [ "$deny_ok" -eq 1 ]; then
280
+ payload=$(python3 "$NOTES_PY" encode --verb DISPATCH --actor "$ENTROPY_ACTOR" \
281
+ --field "brief=$brief" --field "interrogation=$interrogation_field" \
282
+ --field "scope=$files" --field "denylist=$deny")
283
+ else
284
+ payload=$(python3 "$NOTES_PY" encode --verb DISPATCH --actor "$ENTROPY_ACTOR" \
285
+ --field "brief=$brief" --field "interrogation=$interrogation_field" \
286
+ --field "scope=$files")
287
+ fi
288
+ if ! out=$("$ENTROPY_MACHINES_HOME/bin/tracker" remember --issue "$id" "$payload" 2>&1); then
289
+ echo "dispatch: REFUSED — could not record the brief on $id." >&2
290
+ echo " bin/tracker said: $out" >&2
291
+ echo " Nothing is dispatched blind: without this note, no other session can" >&2
292
+ echo " see what was handed out, and the denylist above is unrecorded. Fix" >&2
293
+ echo " the tracker (docs/TRACKER-ADAPTER.md, docs/CONFIG.md) and rerun." >&2
294
+ exit 1
295
+ fi
296
+
297
+ # CLAIM THE ISSUE IN THE SAME ACT THAT RECORDS THE BRIEF. Recording a
298
+ # DISPATCH note with no claim left status untouched, so a second session
299
+ # could see an issue with a live agent on it as free work. The adapter's
300
+ # `claim` op does this atomically and is never fatal here — losing the
301
+ # dispatch over a claim hiccup would be worse than an unclaimed issue.
302
+ if claim_out=$("$ENTROPY_MACHINES_HOME/bin/tracker" claim "$id" 2>&1); then
303
+ case "$claim_out" in
304
+ *'"claimed": false'*|*'"claimed":false'*)
305
+ echo "dispatch: NOTE — $id is not notstarted; not claiming it. Re-dispatching" >&2
306
+ echo " finished or already-progressing work is a correction; set the status" >&2
307
+ echo " yourself if that is what you mean." >&2 ;;
308
+ *) echo "dispatch: $id claimed." ;;
309
+ esac
310
+ else
311
+ claim_rc=$?
312
+ if [ "$claim_rc" -eq 4 ]; then
313
+ echo "dispatch: WARNING — $id is already claimed by another actor: $claim_out" >&2
314
+ else
315
+ echo "dispatch: WARNING — could not claim $id; it may still read unclaimed to other sessions ($claim_out)." >&2
316
+ fi
317
+ fi
318
+ fi
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # THE AGENT'S CONTEXT FILE
322
+ # ---------------------------------------------------------------------------
323
+ # WHY A FILE AND NOT MORE TERMINAL OUTPUT. Whether the tracker and any local
324
+ # docs directory are reachable from inside a worktree depends on this
325
+ # project's config.json worktree.linkPaths and on whether this clone's
326
+ # hooks are installed — don't assume either way. The one channel that
327
+ # reliably lands is what the brief PASTES (see the measured numbers in the
328
+ # header), so everything is written here and the brief's first line is this
329
+ # file's path.
330
+ #
331
+ # WHY PER-ISSUE AND NOT A SINGLE FILE AT THE ROOT. A dispatch round briefs N
332
+ # agents back to back from one checkout; a fixed filename means agent A's
333
+ # file is overwritten by agent B's dispatch before A ever reads it, and A
334
+ # then reads B's issue, scope and brief as its own — worse than absent,
335
+ # because nothing looks wrong. One file per issue cannot collide.
336
+ #
337
+ # WHY IT LIVES IN THE MAIN CHECKOUT. It is untracked, and `git worktree add`
338
+ # materialises a commit — untracked files are not carried in. So the path
339
+ # printed in the brief is ABSOLUTE and points back at this checkout, which
340
+ # the agent can read (its worktree lives under it).
341
+ ctxdir="$ENTROPY_MACHINES_ROOT/.dispatch-context"
342
+ ctx="$ctxdir/$id.md"
343
+
344
+ # The three carry-forward fields an incoming agent can act on, read out of
345
+ # the LAST HANDOFF note on this issue via lib/notes.py's verb-anchored
346
+ # lookup (not a free-text scan). verified/scope-released are the lander's
347
+ # bookkeeping, not the next agent's context, so they are left out.
348
+ notes_raw=$("$ENTROPY_MACHINES_HOME/bin/tracker" notes --issue "$id" 2>/dev/null || true)
349
+ carry=$(printf '%s\n' "$notes_raw" | python3 "$NOTES_PY" record --issue "$id" --verb HANDOFF --last 2>/dev/null | python3 -c '
350
+ import json, sys
351
+ line = sys.stdin.readline()
352
+ if not line.strip():
353
+ raise SystemExit(0)
354
+ rec = json.loads(line)
355
+ fields = rec.get("fields") or {}
356
+ for key, label in (("next", "next-agent"), ("found", "found-not-fixed"), ("assumed", "assumed")):
357
+ v = fields.get(key)
358
+ if not v:
359
+ continue
360
+ items = v if isinstance(v, list) else [v]
361
+ for item in items:
362
+ item = str(item).strip()
363
+ if item:
364
+ print(f" - [{label}] {item}")
365
+ ' 2>/dev/null || true)
366
+
367
+ # Human-readable rendering of a JSONL note stream, shared by the context
368
+ # file's notes section and the terminal's PRIOR NOTES section below. Reads
369
+ # already-parsed records (json.loads per line) — the note-format parsing
370
+ # itself (verb anchoring, scope grammar) stays in lib/notes.py; this only
371
+ # formats fields for a person to read.
372
+ render_notes() {
373
+ python3 -c '
374
+ import json, sys
375
+ any_line = False
376
+ for line in sys.stdin:
377
+ line = line.strip()
378
+ if not line:
379
+ continue
380
+ try:
381
+ r = json.loads(line)
382
+ except json.JSONDecodeError:
383
+ continue
384
+ any_line = True
385
+ ts, verb, actor = r.get("ts", "?"), r.get("verb", "?"), r.get("actor", "?")
386
+ fields = r.get("fields") or {}
387
+ parts = []
388
+ for k in sorted(fields):
389
+ v = fields[k]
390
+ if isinstance(v, list):
391
+ v = ", ".join(str(x) for x in v)
392
+ parts.append(f"{k}: {v}")
393
+ print(f" [{ts}] {verb} (by {actor}) " + " | ".join(parts))
394
+ if not any_line:
395
+ print(" (none)")
396
+ '
397
+ }
398
+
399
+ if [ "$dry" -eq 0 ]; then
400
+ issue_json=$("$ENTROPY_MACHINES_HOME/bin/tracker" show "$id" 2>&1) || issue_json="UNAVAILABLE — bin/tracker could not be reached. It said:
401
+
402
+ $issue_json
403
+
404
+ Whether the tracker is reachable from inside your worktree depends on this
405
+ project's config.json worktree.linkPaths and on whether this clone's hooks
406
+ are installed. Ask the dispatching session for the issue text if you need it."
407
+
408
+ notes_rendered=$(printf '%s\n' "$notes_raw" | render_notes)
409
+
410
+ # Not capped: an id with a long history is exactly the case the audit is
411
+ # for.
412
+ landed=$(git log --oneline --grep="$id" || true)
413
+ [ -n "$landed" ] || landed="(nothing — git log --oneline --grep=$id is empty; no commit in this checkout names this issue)"
414
+
415
+ carry_text="$carry"
416
+ [ -n "$carry_text" ] || carry_text="(nothing recorded — no earlier HANDOFF note on this issue)"
417
+
418
+ suites_text=$(cfg_get suites | python3 -c '
419
+ import json, sys
420
+ try:
421
+ suites = json.loads(sys.stdin.read() or "[]")
422
+ except Exception:
423
+ suites = []
424
+ if not suites:
425
+ print(" (none configured — see config.json suites)")
426
+ for s in suites:
427
+ name = s.get("name", "?")
428
+ cmd = " ".join(s.get("cmd") or [])
429
+ tag = s.get("tag")
430
+ suffix = " [" + tag + "]" if tag else ""
431
+ print(" " + name + suffix + ": " + cmd)
432
+ ')
433
+
434
+ link_paths=$(cfg_get worktree.linkPaths)
435
+
436
+ mkdir -p "$ctxdir"
437
+ {
438
+ echo "# Dispatch context — $id"
439
+ echo
440
+ echo "Written by bin/dispatch on $(date -u '+%Y-%m-%dT%H:%M:%SZ') from $ENTROPY_MACHINES_ROOT ($branch @ $head)."
441
+ echo "READ THIS BEFORE YOU DO ANYTHING ELSE. Everything you cannot look up for"
442
+ echo "yourself was captured here at dispatch time — whether the tracker and any"
443
+ echo "local docs directory are reachable from your worktree depends on this"
444
+ echo "project's config.json worktree.linkPaths and on whether this clone's"
445
+ echo "hooks are installed; don't assume either way."
446
+ echo
447
+ echo "## Files you must NOT write — CLAIMED by another agent right now"
448
+ echo
449
+ if [ "$deny_ok" -eq 0 ]; then
450
+ echo " UNAVAILABLE — bin/tracker could not be reached, so the live-claim"
451
+ echo " log could not be read. Treat the advisory list below as a HARD"
452
+ echo " boundary instead: an unreadable claim log is not an empty one."
453
+ elif [ -z "$deny" ]; then
454
+ echo " (none — no other issue holds a live claim this round)"
455
+ echo
456
+ echo "Every file in this repo is yours to write if the fix genuinely needs it."
457
+ else
458
+ echo "$claim_lines"
459
+ echo
460
+ echo "Every OTHER file in this repo is yours to write if the fix genuinely"
461
+ echo "needs it. Say in HANDOFF.md's changed: line what you took beyond the"
462
+ echo "advisory list below, so the lander reads that diff on purpose."
463
+ fi
464
+ echo
465
+ echo "Derived from the shared notes log, not from a guess: an issue whose last"
466
+ echo "event is a DISPATCH holds its scope, a HANDOFF releases it, and a claim"
467
+ echo "by this issue's own id is not a claim against you."
468
+ echo
469
+ echo "### If your fix needs a CLAIMED file: STOP AND REPORT. This is a refusal,"
470
+ echo "### not a preference."
471
+ echo
472
+ echo "An agent that finds it needs a claimed file has three options — write it"
473
+ echo "anyway and risk clobbering a concurrent agent, ship the half it was"
474
+ echo "allowed to write, or stop. This workflow has, more than once, watched an"
475
+ echo "agent take the middle option and report success: two halves of one"
476
+ echo "change individually green, the change itself broken, because each half"
477
+ echo "was written by an agent that could not reach the other's file. Neither"
478
+ echo "agent said it was blocked. The cost lands on the lander, who is the one"
479
+ echo "context that can see both halves — exactly the serialisation this model"
480
+ echo "exists to avoid. So:"
481
+ echo
482
+ echo " 1. STOP at the boundary. Do not write the claimed file, and do not"
483
+ echo " ship the half of the change that does not work without it."
484
+ echo " 2. Leave the in-scope work UNCOMMITTED AND COHERENT — it must build"
485
+ echo " and test on its own. If it cannot, say so in changed: rather than"
486
+ echo " leaving a green half that is wrong on the wire."
487
+ echo " 3. Put the blocked half in HANDOFF.md's found: line, naming the EXACT"
488
+ echo " file and what it must contain. 'needed a file outside scope' is"
489
+ echo " not enough for the lander to finish it."
490
+ echo " 4. Say the same in your final report, in the same words."
491
+ echo
492
+ echo "WHAT ENFORCES THIS: nothing mechanical, and pretending otherwise would be"
493
+ echo "worse than saying it. Nothing can detect a half-change from outside — the"
494
+ echo "mechanism is this wording plus the lander reading your found: line, and"
495
+ echo "bin/handoff refuses a record with no --found and no --next unless the"
496
+ echo "lander passes --clean as a positive claim."
497
+ echo
498
+ echo "## What the dispatcher expected you to touch — ADVISORY, not a boundary"
499
+ echo
500
+ echo " $files"
501
+ echo
502
+ echo "This is a prediction made before anyone read the code, by the party that"
503
+ echo "has not read it. It is recorded for the audit trail and it is NOT the"
504
+ echo "limit of what you may write. The enforced limit is the claimed list above."
505
+ echo
506
+ echo "## Your brief"
507
+ echo
508
+ echo " $brief"
509
+ echo
510
+ echo "## You will be QUESTIONED before your work is lifted"
511
+ echo
512
+ echo "When you stop, you are still resumable and you still hold context nobody"
513
+ echo "else has. The lander will put these questions to you and record your"
514
+ echo "answers on the issue; bin/handoff refuses to record the landing until it"
515
+ echo "has them. Gather them AS YOU WORK — they are much harder to reconstruct"
516
+ echo "afterwards, and a wrong answer is worse than a blank one."
517
+ echo
518
+ qs=$("$ENTROPY_MACHINES_HOME/bin/handoff" "$id" --questions 2>/dev/null || true)
519
+ if [ -n "$qs" ]; then echo "$qs"
520
+ else echo " (unavailable — bin/handoff --questions could not be read here)"; fi
521
+ echo
522
+ echo "None of these can be answered with \"no\", and that is deliberate. Both"
523
+ echo "recorded half-changes above were reported as successes by the agent that"
524
+ echo "shipped them, so \"are you complete?\" is worse than asking nothing. At"
525
+ echo "least one of your answers must be substantive — \"nothing\" across the"
526
+ echo "whole set is refused, so silence has to be a claim you made rather than"
527
+ echo "an absence."
528
+ echo
529
+ echo "## The issue (bin/tracker show $id)"
530
+ echo
531
+ echo "$issue_json"
532
+ echo
533
+ echo "## This issue's notes (bin/tracker notes --issue $id)"
534
+ echo
535
+ echo "$notes_rendered"
536
+ echo
537
+ echo "## What already landed for this issue (git log --oneline --grep=$id)"
538
+ echo
539
+ echo "IF THIS IS NON-EMPTY, AUDIT IT BEFORE WRITING CODE. An issue whose desc has"
540
+ echo "gone stale reads as unstarted work — treat a non-empty match as a reason"
541
+ echo "to look before reimplementing something that already landed under this id."
542
+ echo
543
+ echo "$landed"
544
+ echo
545
+ echo "## What earlier work on this issue left you"
546
+ echo
547
+ echo "$carry_text"
548
+ echo
549
+ echo "## This project's suites (config.json)"
550
+ echo
551
+ echo "$suites_text"
552
+ echo
553
+ echo "Never run this project's full build/release command in your worktree —"
554
+ echo "some rewrite committed bookkeeping (a collated changelog, a checkpoint"
555
+ echo "file) as a side effect. Use the suites above."
556
+ echo
557
+ echo "## Shared paths linked into your worktree, IF this clone's hooks are"
558
+ echo "## installed (config.json worktree.linkPaths: ${link_paths:-[]})"
559
+ echo
560
+ echo "Don't assume either way — a resolver that can't find a dependency here"
561
+ echo "will sometimes walk up and resolve it out of a SIBLING agent's worktree"
562
+ echo "instead, which means testing someone else's code and reporting it as"
563
+ echo "yours. If a suite refuses to run because it can't resolve its own"
564
+ echo "dependencies, that is the refusal working as designed — don't work"
565
+ echo "around it by hand."
566
+ echo
567
+ echo "## Before you finish: write HANDOFF.md at the root of YOUR worktree"
568
+ echo
569
+ echo "Exactly these four keys, ONE LINE each, no prose, under six lines total:"
570
+ echo
571
+ echo " changed: <what you changed>"
572
+ echo " found: <seen but not fixed, out of scope — or: none>"
573
+ echo " assumed: <what you took as given — or: none>"
574
+ echo " next: <what the next agent needs to know — or: none>"
575
+ echo
576
+ echo "Do not commit it; it is gitignored. found and next are why the file exists"
577
+ echo "— your diff already says what changed. Do not commit, push or merge anything"
578
+ echo "at all: the dispatching session lifts your worktree and lands it."
579
+ } > "$ctx"
580
+ echo "dispatch: context written — $ctx"
581
+ fi
582
+
583
+ # The issue's own note thread, printed at the moment a brief is being
584
+ # written, so a prior agent's findings on the same issue don't stay unread
585
+ # by the next one. This was fetched BEFORE the DISPATCH note above was
586
+ # written, so it is naturally prior — no filtering needed.
587
+ if [ -n "$notes_raw" ]; then
588
+ echo
589
+ echo "PRIOR NOTES ON $id — read before briefing; fold anything load-bearing into the brief:"
590
+ printf '%s\n' "$notes_raw" | render_notes | sed 's/^/ /'
591
+ fi
592
+
593
+ echo
594
+ if [ "$dry" -eq 1 ]; then
595
+ echo "dispatch: OK (dry run) — nothing recorded on $id."
596
+ else
597
+ echo "dispatch: OK — brief + scope recorded on $id."
598
+ fi
599
+ echo "Dispatch the worker defined in agents/isolated-worker.md."
600
+ echo " Claude Code: Agent tool, subagent_type: isolated-worker."
601
+ echo " A different runner: give it its own worktree, this brief directly (not"
602
+ echo " just pointed at it), and a way for you to read back its HANDOFF.md —"
603
+ echo " see the note at the top of agents/isolated-worker.md."
604
+ echo " issue: $id"
605
+ echo " advisory --files: $files"
606
+ echo " ENFORCED denylist: $deny_field"
607
+ echo " brief: $brief"
608
+ echo
609
+ # Pasted verbatim into the agent's brief. Four keys, one line each, and the
610
+ # instruction says so twice — an agent given a free-form "write up what you
611
+ # found" returns prose the lander then has to read and compress, which is
612
+ # the work this was meant to remove. The file is gitignored, so the agent
613
+ # cannot accidentally add it to the tree.
614
+ echo "PASTE INTO THE AGENT'S BRIEF:"
615
+ echo " BRIEF: $brief"
616
+ echo
617
+ # FIRST LINE after the brief, and the load-bearing one — see the measured
618
+ # numbers in the header. This holds regardless of whether your runner
619
+ # auto-loads a project-instructions file into subagents: proximity to the
620
+ # prompt is what changes behaviour, not presence in context.
621
+ echo " FIRST, before anything else: read this file, in full, with one Read:"
622
+ echo " $ctx"
623
+ echo " It is your issue text, what already landed under this id, your scope and"
624
+ echo " brief, what earlier agents left you, and this project's suites. It is in"
625
+ echo " the MAIN checkout, NOT in your worktree; the path above is absolute, use"
626
+ echo " it as-is."
627
+ if [ "$dry" -eq 1 ]; then
628
+ echo " (--dry-run: that file was NOT written. Rerun without --dry-run.)"
629
+ fi
630
+ # The cwd check, stated to the AGENT. This script already refuses a
631
+ # non-root cwd, but that runs when the brief is written and the dispatch
632
+ # call happens after — a `cd` in between still misroutes the worktree, and
633
+ # has once taken out three agents at once this way. Nothing in the tool
634
+ # chain can catch that on the way in, so the agent checks on the way out.
635
+ #
636
+ # --git-common-dir answers WHICH REPO IS THIS A WORKTREE OF. From a linked
637
+ # worktree it resolves to the MAIN repo's .git as an ABSOLUTE path, so it
638
+ # carries the repo's own path. From a non-worktree checkout at its root it
639
+ # prints a bare relative `.git`, which is why the wording calls that out as
640
+ # its own reportable state rather than leaving the agent to interpret it.
641
+ # --show-toplevel is the wrong check here: it prints the WORKTREE's own
642
+ # path, which can never equal the main repo's path, so it can never confirm
643
+ # which repo the worktree belongs to.
644
+ echo " Your scope is a DENYLIST, not an allowlist. These files are claimed by"
645
+ echo " another agent RIGHT NOW and you must NOT write them: $deny_field"
646
+ echo " Everything else is yours if the fix needs it. If your fix needs a CLAIMED"
647
+ echo " file: STOP — do not write it, and do NOT ship the half you were allowed"
648
+ echo " to write. Leave the in-scope work coherent and name the exact file and"
649
+ echo " what it must contain in HANDOFF.md's found: line. The context file above"
650
+ echo " has the full protocol and says what does and does not enforce it."
651
+ echo " SECOND, before you write anything: run \`git rev-parse --git-common-dir\`."
652
+ echo " It prints the MAIN repo's .git — which repo your worktree branched from."
653
+ echo " It MUST contain /$(basename "$ENTROPY_MACHINES_ROOT")/. A bare relative \`.git\` means you"
654
+ echo " are not in a worktree at all: report that."
655
+ echo " If either is wrong, STOP and report it. NEVER cd to make it pass and"
656
+ echo " never write to the shared checkout — a cd is the failure, not the fix."
657
+ echo " Do NOT use --show-toplevel here: yours is the worktree path, so it can"
658
+ echo " never end in /$(basename "$ENTROPY_MACHINES_ROOT") and tells you nothing about the repo."
659
+ # THE SCRATCHPAD, IN THE PASTE BLOCK RATHER THAN ONLY IN A DOC. The same
660
+ # measurement that shapes the rest of this block puts a pasted rule at ~96%
661
+ # and one sitting only in a doc the agent was told to go read at far less —
662
+ # and a scratchpad the worker never hears about is the shared one it was
663
+ # already using.
664
+ echo " YOUR PRIVATE SCRATCHPAD is \`.scratch\` at the root of your worktree —"
665
+ echo " mutators, probe harnesses, captured suite output go THERE, not in any"
666
+ echo " scratchpad shared with the agents running beside you. Two concurrent"
667
+ echo " agents have overwritten each other's throwaway files by both reaching"
668
+ echo " for a shared scratch directory instead. If a tool refuses to write"
669
+ echo " through .scratch, say so in HANDOFF.md — do not fall back to a shared"
670
+ echo " scratchpad silently."
671
+ # Earlier agents' carry-forward lines, INSIDE the paste block rather than
672
+ # only in the context file above it, for the same measured reason.
673
+ if [ -n "$carry" ]; then
674
+ echo " WHAT EARLIER WORK ON THIS ISSUE LEFT YOU (you may not be able to look"
675
+ echo " this up yourself — see the context file for how to check):"
676
+ echo "$carry"
677
+ echo
678
+ fi
679
+ echo " Before you finish, write HANDOFF.md at the root of your worktree."
680
+ echo " Exactly these four keys, ONE LINE each, no prose:"
681
+ echo " changed: <what you changed>"
682
+ echo " found: <seen but not fixed, out of scope — or: none>"
683
+ echo " assumed: <what you took as given — or: none>"
684
+ echo " next: <what the next agent needs to know — or: none>"
685
+ echo " Keep it under six lines total. Do not commit it; it is gitignored."
686
+ echo " AFTER YOU STOP YOU WILL BE QUESTIONED, while you are still resumable, and"
687
+ echo " your work is not lifted until your answers are recorded. The questions are"
688
+ echo " adversarial on purpose — what you did NOT run, which of your assertions is"
689
+ echo " weakest, what you took on trust, what in the brief you skipped, what you"
690
+ echo " would check next. They are listed in the context file above; note the"
691
+ echo " answers as you work. \"Nothing\" to every one of them is refused."
692
+ echo
693
+ echo "WHEN YOU LAND IT, before committing:"
694
+ echo " bin/handoff $id --interrogate # the questions; send them to the LIVE agent"
695
+ echo " bin/handoff $id --record-interrogation --answer \"...\" # what it said"
696
+ echo " bin/handoff $id --from <agent-worktree> --lift # copies the files IN, refusing any that drifted"
697
+ echo " ...then verify in THIS tree, then:"
698
+ echo " bin/handoff $id --from <agent-worktree> --verified \"what YOU re-ran\""
699
+ echo " (or spell the fields out: --changed \"...\" [--found \"...\"] [--next \"...\"] [--clean])"
700
+ echo " The commit-msg hook refuses a commit naming $id until that exists."