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/handoff ADDED
@@ -0,0 +1,1151 @@
1
+ #!/bin/sh
2
+ # Post-flight record for a dispatched agent's work — the exit half of
3
+ # bin/dispatch.
4
+ #
5
+ # bin/handoff i-foo \
6
+ # --changed "what actually landed" \
7
+ # --verified "how I re-checked it MYSELF" \
8
+ # [--found "seen, not fixed, out of scope"]... (repeatable) \
9
+ # [--assumed "what I took as given"]... (repeatable) \
10
+ # [--next "what the next agent needs to know"] \
11
+ # [--clean] (required when there is no --found and no --next)
12
+ #
13
+ # bin/handoff i-foo --from .claude/worktrees/agent-abc --verified "..."
14
+ #
15
+ # bin/handoff i-foo --from .claude/worktrees/agent-abc --lift
16
+ # Copy the worker's edits into THIS checkout, refusing every file that
17
+ # moved on main since the worker branched, and every file another issue
18
+ # was holding when this agent was dispatched — except a held path that
19
+ # does not exist here at all, which is a new file and cannot be a clobber
20
+ # (reported, not refused). Records nothing.
21
+ #
22
+ # bin/handoff i-foo --interrogate [--ask "one question of your own"]
23
+ # Print the adversarial question set. Send it to the STILL-RUNNING agent.
24
+ #
25
+ # bin/handoff i-foo --record-interrogation --answer "..." (one per question)
26
+ # Record what it answered. Required before the record step will run.
27
+ #
28
+ # The --from form lifts the agent's own four lines out of <worktree>/HANDOFF.md
29
+ # so the lander edits instead of retyping. --verified is never lifted.
30
+ #
31
+ # WHY THIS EXISTS. bin/dispatch records the brief going IN. Nothing
32
+ # recorded what came OUT. A landed agent leaves a diff and a changelog
33
+ # fragment, and both describe the change — neither describes the dead end it
34
+ # ruled out, the coupling it tripped over, the thing it saw two files away and
35
+ # left alone, or the assumption its work rests on. That knowledge existed in
36
+ # exactly one place (a worktree that gets deleted) and was reconstructed from
37
+ # scratch by the next agent to touch the same code. It has happened repeatedly:
38
+ # an open question, a deliberate deferral and a known gap in a generated
39
+ # wrapper were each rediscovered by a later session after an earlier one had
40
+ # already established them and left no record.
41
+ #
42
+ # WHO RUNS IT. The LANDING session, not the agent. Agents leave edits
43
+ # uncommitted and never commit (see agents/isolated-worker.md rule 1), so the
44
+ # session that reads the diff and commits is the one holding the knowledge
45
+ # worth recording — and it is the one that verified the work independently,
46
+ # which the agent cannot do for itself. An agent MAY leave a HANDOFF.md in its
47
+ # worktree and the lander is free to lift text from it, but the record is the
48
+ # lander's statement, not a relay.
49
+ #
50
+ # WHY --verified IS MANDATORY AND WHY IT REFUSES RELAYED TEXT. Dispatch rule 5
51
+ # — "verify before committing, independently" — was enforced by nothing.
52
+ # Subagents have reported passing work that was wrong (an assertion that proved
53
+ # nothing; a test titled "regression guard" that passed on the unfixed code).
54
+ # A --verified that reads "agent reports tests pass" is the failure the rule
55
+ # names, so it is refused by pattern. This cannot tell whether you really ran
56
+ # the command; it can refuse a sentence that admits you did not.
57
+ #
58
+ # WHY SILENCE MUST BE DELIBERATE. --found and --next are the fields with the
59
+ # actual salvage value, and they are the two an author in a hurry omits. Omit
60
+ # both and this refuses unless you pass --clean, which turns "nothing to report"
61
+ # from an absence into a claim someone made.
62
+ #
63
+ # WHY --lift EXISTS. Landing meant copying
64
+ # the worker's files out of its worktree by hand — `cp <worktree>/<f> <f>` —
65
+ # and nothing compared <f> against what had landed on main since the worker
66
+ # branched. A stale copy silently reverts landed work, and twice it nearly did:
67
+ #
68
+ # 2026-08-22 copying a worker's run-host.ts reverted select_option out of
69
+ # the CDP host; cli-tool-reachability went red 2/2. The rule in
70
+ # force at the time covered GENERATED files only; this was source.
71
+ # A worker regenerated a committed bundle inside its own tree, from a base
72
+ # that predated a fix landed twenty minutes earlier. Copying its
73
+ # bundle would have reverted that fix. Caught
74
+ # only because the lander ran `git log <base>..HEAD -- <file>` by
75
+ # hand, from memory.
76
+ #
77
+ # A mechanism that depends on a human remembering is the thing that failed. For
78
+ # every file the worker touched, --lift asks git whether main moved that file
79
+ # since the worker's base commit, copies the ones it did not, and refuses the
80
+ # ones it did.
81
+ #
82
+ # WHY IT COPIES RATHER THAN ONLY REFUSING. A check that refuses and stops
83
+ # leaves the `cp` where it is — and a refusal you satisfy by typing the same cp
84
+ # anyway is advice, not a mechanism. The cost is real: this writes into the
85
+ # shared main checkout, which another session may be live in. It is bounded on
86
+ # purpose — it writes only paths the worker changed AND that fall inside the
87
+ # file scope bin/dispatch recorded, it never deletes anything, it never
88
+ # touches a file main has moved, and it prints every path it wrote.
89
+ #
90
+ # WHY A DRIFTED GENERATED FILE IS A DIFFERENT REFUSAL FROM A DRIFTED SOURCE
91
+ # FILE. They have opposite remedies, and one message for both pushes the lander
92
+ # toward force-copying, which is the 2026-08-23 near-miss exactly. A generated
93
+ # file is never merged and never copied: the source lands, `npm run gen` runs
94
+ # HERE, and the lander confirms the regenerated bundle still carries what landed
95
+ # earlier. A source file needs a real merge or a re-dispatch from current HEAD.
96
+ # The generated set is DECLARED, in config.json's `generate.outputs`. An
97
+ # earlier version derived it by reading the project's generator sources and
98
+ # regexing their output paths out — which rots the moment a project writes its
99
+ # generators differently, and rots SILENTLY: an empty derived set is
100
+ # indistinguishable from "no generated file was touched", which is the exact
101
+ # case this check exists to catch. A declared list can also go stale, but it
102
+ # goes stale loudly — `generate.cmd` set with no `outputs` prints a ROT
103
+ # warning, and the output degrades to the SOURCE message —
104
+ # still a refusal, never a copy.
105
+ #
106
+ # WHY --lift HAS NO OVERRIDE. bin/dispatch refuses a wrong cwd and a dirty
107
+ # scope with no escape hatch "because there is no correct dispatch from a wrong
108
+ # cwd or over your own uncommitted edits". A blind copy over a moved file is in
109
+ # that class: there is no correct one. What IS legitimate is a lander who has
110
+ # read both diffs and concluded the worker's version is the right merge result
111
+ # — and that lander can still type `cp`, so nothing is lost by not offering a
112
+ # flag for it. What is not legitimate is reaching for --force at the moment the
113
+ # check fires, which is when it would always be reached for.
114
+ #
115
+ # WHY THE RECORD STEP RE-CHECKS ANYWAY. --lift is not the only way files move,
116
+ # and both incidents happened at cp time, not at record time. So whenever
117
+ # --from is given, the record step compares once more: a file that drifted on
118
+ # main and whose content in this checkout is now byte-identical to the worker's
119
+ # version is a stale copy that reverted landed work, and it is refused.
120
+ # --drift-reviewed "<why>" is the way past that one, and the reason is written
121
+ # into the note — an override that lands on the record as a claim, which is the
122
+ # same shape as --clean.
123
+ #
124
+ # WHY THE AGENT IS INTERROGATED FIRST.
125
+ # Everything above is a monologue: the worker writes HANDOFF.md, the lander
126
+ # reads it, and nobody ever puts a question to the one party still holding the
127
+ # full context. The agent is resumable when it stops — the questions go to it
128
+ # live and its answers come back here. On 2026-08-26 the worker knew two things
129
+ # the verifier and the lander then each re-derived from scratch.
130
+ #
131
+ # THE QUESTIONS ARE ADVERSARIAL, NOT A COMPLETION CHECK, and that is the whole
132
+ # design. Both recorded half-change incidents had the agent report SUCCESS on
133
+ # broken work: one shipped a writer emitting an event kind it could not declare
134
+ # in the shared type module; another shipped a version field on the wire with
135
+ # nothing producing it. Both halves individually green, both agents said they
136
+ # were done. "Are you
137
+ # complete?" returns yes in exactly the cases that matter, so it launders the
138
+ # failure rather than catching it. Every question below asks for something that
139
+ # cannot be answered with "no", and at least one answer must be substantive —
140
+ # so "nothing" is a claim someone made rather than an absence, the same shape
141
+ # as --clean.
142
+ #
143
+ # ONE ROUND, PLUS AT MOST ONE QUESTION OF THE LANDER'S OWN (--ask). An unbounded
144
+ # loop reintroduces the round trips this repo keeps trying to remove, and a
145
+ # fixed set is a set dispatch can show the agent up front, so it gathers the
146
+ # answers as it works instead of reconstructing them after the fact.
147
+ #
148
+ # THE GATE ENFORCES THE CONTRACT THE AGENT WAS ACTUALLY GIVEN. bin/dispatch
149
+ # writes `— interrogation: required` into the DISPATCH note and pastes the
150
+ # questions into the brief, so a note that carries the field is an agent that
151
+ # was TOLD it would be questioned. A note without it (every note written before
152
+ # this landed, and every hand-done piece of work) is not gated: refusing to
153
+ # record work whose agent was never asked anything, and can no longer be asked,
154
+ # punishes the lander for a contract nobody signed. Same three-state shape as
155
+ # the denylist field above.
156
+ #
157
+ # WHY THE HARD REFUSAL IS ON THE RECORD STEP AND NOT ONLY ON --lift. --lift is
158
+ # bypassable by design — the header above says a lander who disagrees "can still
159
+ # type cp" — while the record step is not bypassable at all: the commit-msg hook
160
+ # refuses a commit naming a dispatched id until the HANDOFF note exists. So the
161
+ # refusal lives where it actually holds, and --lift refuses too whenever the
162
+ # worker left a HANDOFF.md (i.e. every real landing).
163
+ set -e
164
+
165
+ usage() {
166
+ echo "usage: bin/handoff <issue-id> --changed \"...\" --verified \"...\"" >&2
167
+ echo " [--found \"...\"]... [--assumed \"...\"]... [--next \"...\"] [--clean]" >&2
168
+ echo " [--drift-reviewed \"why the worker's version is right\"]" >&2
169
+ echo " or: bin/handoff <issue-id> --from <agent-worktree> --verified \"...\"" >&2
170
+ echo " or: bin/handoff <issue-id> --from <agent-worktree> --lift (copy, record nothing)" >&2
171
+ echo " or: bin/handoff <issue-id> --interrogate [--ask \"...\"] (print the questions)" >&2
172
+ echo " or: bin/handoff <issue-id> --record-interrogation --answer \"...\" (one per question)" >&2
173
+ exit 2
174
+ }
175
+
176
+ id=""; changed=""; verified=""; next_note=""; clean=0; dry=0
177
+ found=""; assumed=""; from=""; lift=0; drift_reviewed=""
178
+ interrogate=0; record_interrogation=0; ask=""; answers=""; n_answers=0; blank_answer=0
179
+ no_interrogation=""
180
+ while [ $# -gt 0 ]; do
181
+ case "$1" in
182
+ --changed) changed="$2"; shift 2 ;;
183
+ --verified) verified="$2"; shift 2 ;;
184
+ --next) next_note="$2"; shift 2 ;;
185
+ --found) found="$found${found:+; }$2"; shift 2 ;;
186
+ --assumed) assumed="$assumed${assumed:+; }$2"; shift 2 ;;
187
+ --clean) clean=1; shift ;;
188
+ # Lift the agent's own four lines out of <worktree>/HANDOFF.md instead of
189
+ # retyping them. Explicit flags still win, and --verified is never lifted.
190
+ --from) from="$2"; shift 2 ;;
191
+ # Copy the worker's edits into this checkout instead of `cp` by hand.
192
+ # Records nothing: verification belongs between the copy and the record.
193
+ --lift) lift=1; shift ;;
194
+ # The one override, on the record side only, and it costs a sentence that
195
+ # goes into the permanent note.
196
+ --drift-reviewed) drift_reviewed="$2"; shift 2 ;;
197
+ # Print the question set for the still-running agent, and stop.
198
+ --interrogate) interrogate=1; shift ;;
199
+ # The questions and nothing else, so bin/dispatch can show the agent the
200
+ # exact set it will be held to without keeping a second copy of it.
201
+ --questions) interrogate=2; shift ;;
202
+ # Record what it answered.
203
+ --record-interrogation) record_interrogation=1; shift ;;
204
+ # One question of the lander's own, at most. Passed to --interrogate to be
205
+ # printed, and to --record-interrogation so the note says what was asked.
206
+ --ask) ask="$2"; shift 2 ;;
207
+ # One per question, in the order they were printed. Newlines are collapsed
208
+ # on receipt: a note is one line, and an answer that could contain one could
209
+ # forge a whole log entry.
210
+ --answer)
211
+ a=$(printf '%s' "$2" | tr '\n\r\t' ' ')
212
+ case "$a" in *[!\ ]*) ;; *) blank_answer=1 ;; esac
213
+ answers="${answers}${a}
214
+ "
215
+ n_answers=$((n_answers + 1)); shift 2 ;;
216
+ # The escape hatch, and it lands on the record as a positive claim rather
217
+ # than an absence — the same shape as --clean and --drift-reviewed.
218
+ --no-interrogation) no_interrogation="$2"; shift 2 ;;
219
+ # Print the note and write nothing. The log is shared and append-only with
220
+ # no delete, so "let me see what this records" needs an answer that is not
221
+ # "record it and find out".
222
+ --dry-run) dry=1; shift ;;
223
+ -*) usage ;;
224
+ *) [ -n "$id" ] && usage; id="$1"; shift ;;
225
+ esac
226
+ done
227
+
228
+ . "$(dirname "$0")/../lib/roots.sh"
229
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
230
+ entropy_machines_require_root handoff
231
+
232
+ # The dispatch note carries two lists and they do opposite jobs
233
+ #:
234
+ #
235
+ # scope: the ADVISORY --files the dispatcher predicted. Recorded for the
236
+ # audit trail and echoed back on the record so it closes the same
237
+ # claim it opened. A file outside it is REPORTED, not refused —
238
+ # that prediction is made before anyone has read the code, and
239
+ # refusing on it is what bounced a worker's own brand-new
240
+ # changelog.d fragment on 2026-08-26.
241
+ # denylist: the files OTHER issues were holding when this agent was
242
+ # dispatched. THIS is what --lift refuses on, exactly as hard as it
243
+ # used to refuse an out-of-allowlist file: another agent's
244
+ # concurrent edits are the one thing a blind copy really can
245
+ # destroy.
246
+ #
247
+ # THREE STATES on the denylist field, and the third is why this is not a
248
+ # softening. A real list is enforced. `(none)` means the claim log was read and
249
+ # nobody held anything. `(unavailable)` — or a note written before this field
250
+ # existed at all, which is every note in the log today — means the claim set is
251
+ # unknown, and then the ADVISORY list is enforced as an allowlist, i.e. the old
252
+ # behaviour verbatim. There is no state in which nothing is checked.
253
+ # READ THROUGH lib/notes.py, NOT WITH A sed. Both lists used to be scraped out
254
+ # of a rendered em-dash line — `DISPATCH <id> — scope: … — brief: …`. bin/dispatch
255
+ # stopped writing that form and writes a structured record instead
256
+ # ({"verb":"DISPATCH","issue":…,"fields":{"scope":[…],"denylist":[…]}}), and the
257
+ # seds went on matching nothing, silently: scope came back empty, so in_scope
258
+ # said yes to every path; deny came back empty, so deny_known was 0 and the
259
+ # out-of-scope branch enforced an EMPTY allowlist, which also says yes to
260
+ # everything. Both halves of the check passed everything, on every real
261
+ # dispatch, while still printing that they had run.
262
+ #
263
+ # lib/notes.py reads the structured record first and falls back to the rendered
264
+ # string, so a store holding both still works, and lib/handoff-guard.sh reads
265
+ # the same log through the same fallback — one grammar, two callers, no third
266
+ # reimplementation of it here.
267
+ NOTES_PY="$ENTROPY_MACHINES_HOME/lib/notes.py"
268
+ : "${ENTROPY_ACTOR:=handoff}"
269
+ export ENTROPY_ACTOR
270
+
271
+ notes=""
272
+ if [ -n "$id" ]; then
273
+ notes=$("$ENTROPY_MACHINES_HOME/bin/tracker" notes --issue "$id" 2>/dev/null || true)
274
+ fi
275
+
276
+ # dispatch_found and "scope is empty" are DIFFERENT ANSWERS and collapsing them
277
+ # is the whole defect above. No DISPATCH record at all is hand-done work and
278
+ # fails open, by design and loudly ("no DISPATCH note found … recording
279
+ # anyway"). A DISPATCH record whose fields cannot be read is a broken gate, and
280
+ # a gate that cannot read its own state must refuse or decline — never quietly
281
+ # allow, which is the regression that was caught in lib/handoff-guard.sh when
282
+ # only its crash was fixed.
283
+ dispatch_found=0
284
+ scope=""
285
+ deny=""
286
+ istate="no-dispatch"
287
+ notes_readable=1
288
+ if [ -n "$notes" ]; then
289
+ if fields=$(printf '%s\n' "$notes" | python3 "$NOTES_PY" dispatch-fields --issue "$id" 2>/dev/null); then
290
+ dispatch_found=$(printf '%s\n' "$fields" | awk -F'\t' '$1=="DISPATCH"{print $2}')
291
+ scope=$(printf '%s\n' "$fields" | awk -F'\t' '$1=="SCOPE"{print $2}')
292
+ deny=$(printf '%s\n' "$fields" | awk -F'\t' '$1=="DENYLIST"{print $2}')
293
+ else
294
+ notes_readable=0
295
+ fi
296
+ if state_out=$(printf '%s\n' "$notes" | python3 "$NOTES_PY" interrogation-state --issue "$id" 2>/dev/null); then
297
+ istate="$state_out"
298
+ else
299
+ notes_readable=0
300
+ fi
301
+ fi
302
+ case "$dispatch_found" in 0|1) ;; *) dispatch_found=0 ;; esac
303
+
304
+ if [ "$notes_readable" -eq 0 ]; then
305
+ echo "handoff: REFUSED — $id has tracker notes, but $NOTES_PY could not read" >&2
306
+ echo " them. Every check below (declared scope, another agent's claims, whether" >&2
307
+ echo " anyone questioned the agent) reads that log, so proceeding would report" >&2
308
+ echo " clean without having checked anything." >&2
309
+ echo " Reproduce it: bin/tracker notes --issue $id | python3 $NOTES_PY dispatch-fields --issue $id" >&2
310
+ exit 1
311
+ fi
312
+
313
+ if [ "$dispatch_found" -eq 1 ] && [ -z "$scope" ]; then
314
+ echo "handoff: WARNING — the DISPATCH note for $id records NO file scope." >&2
315
+ echo " bin/dispatch cannot produce that (--files is mandatory), so this note was" >&2
316
+ echo " written by hand or truncated. Nothing below can tell an in-scope file from" >&2
317
+ echo " an out-of-scope one; only the recorded denylist is still enforced. Read the" >&2
318
+ echo " whole diff yourself: git -C <worktree> status --porcelain -uall" >&2
319
+ fi
320
+
321
+ # Commas are a second separator in the live log (see dispatch's parser). Both
322
+ # lists are read by unquoted `for f in $x` word-splitting, which does not split
323
+ # on commas, so a comma-joined scope made in_scope match nothing and --lift
324
+ # refuse everything. Normalise once, here, for both lists.
325
+ scope=$(printf '%s' "$scope" | tr ',' ' ')
326
+ deny=$(printf '%s' "$deny" | tr ',' ' ')
327
+
328
+ deny_known=0
329
+ case "$deny" in
330
+ ""|"(unavailable)") deny=""; deny_known=0 ;;
331
+ "(none)") deny=""; deny_known=1 ;;
332
+ *) deny_known=1 ;;
333
+ esac
334
+
335
+ tmp=""
336
+ cleanup() { [ -n "$tmp" ] && rm -rf "$tmp"; return 0; }
337
+ trap cleanup EXIT
338
+
339
+ # A file is inside the scope bin/dispatch recorded. Same annotation
340
+ # stripping as dispatch's clash check ("path/(own)", "path/*.md", "dir/") so
341
+ # both ends read one scope line the same way. No recorded scope (hand-done
342
+ # work) means no scope check.
343
+ in_scope() {
344
+ [ -n "$scope" ] || return 0
345
+ for f in $scope; do
346
+ pat=$(echo "$f" | sed 's|(own)$||; s|/\*.*$||; s|/$||')
347
+ [ -n "$pat" ] || continue
348
+ case "$1" in $pat|$pat/*) return 0 ;; esac
349
+ done
350
+ return 1
351
+ }
352
+
353
+ # The path is on the recorded denylist, i.e. another issue was holding it when
354
+ # this agent was dispatched. Same annotation stripping, so one scope line is
355
+ # read the same way at both ends and in both lists.
356
+ is_claimed() {
357
+ [ "$deny_known" -eq 1 ] || return 1
358
+ for f in $deny; do
359
+ pat=$(echo "$f" | sed 's|(own)$||; s|/\*.*$||; s|/$||')
360
+ [ -n "$pat" ] || continue
361
+ case "$1" in $pat|$pat/*) return 0 ;; esac
362
+ done
363
+ return 1
364
+ }
365
+
366
+ # THE BRAND-NEW-FILE EXEMPTION.
367
+ #
368
+ # A path that does not exist in THIS checkout at the moment of the lift cannot
369
+ # be a clobber of another agent's concurrent edits, whatever the denylist says:
370
+ # there is nothing there to clobber. --lift is creating the file. On 2026-08-27
371
+ # a directory-level claim on `tests/core/` bounced a worker's brand-new
372
+ # tests/core/agent-interrogation.test.ts, and the lander read the diff and
373
+ # copied it by hand — the exact manual step the denylist exists to remove. That
374
+ # was the THIRD costume of this bug; the two before it (changelog.d/, then the
375
+ # changelog.d/ hidden inside a comma-joined scope) were both patched at the
376
+ # specific path, which is why this one is the general rule instead.
377
+ #
378
+ # It is a DOWNGRADE FROM REFUSE TO REPORT, not a silent pass — see the
379
+ # "$tmp/newclaimed" block in the --lift report.
380
+ #
381
+ # $2 KEEPS THIS PREDICATE HONEST; IT IS NOT WHAT CARRIES THE SAFETY. The case
382
+ # that must not go wrong is a file new in the worker's tree but CREATED ON MAIN
383
+ # since the worker branched. That one exists here, so `-e` alone refuses it.
384
+ # The remaining case — main created the path and has since deleted it — was
385
+ # MEASURED at land time (2026-08-27) by deleting the `$2` line: the path is
386
+ # still refused, exit 1, never resurrected, because an exempted path does not
387
+ # `continue` and falls through to the drift branch, where $moved is non-empty.
388
+ # Only the message the lander reads changes.
389
+ #
390
+ # It stays because `absent_from_main` must be true to its own name. Exempting a
391
+ # path main has touched would make this function's safety depend on a branch
392
+ # below it happening to run afterwards — reorder that loop, add a `continue`,
393
+ # and the exemption starts copying drifted claimed files silently. Two lines
394
+ # against a trap this repo has fallen into three times.
395
+ #
396
+ # $2 is the `git log $base..HEAD -- $path` the caller already computed. KNOWN
397
+ # HOLE, tested not reasoned: if main's history is rewritten so the commit that
398
+ # created the path is no longer reachable, that log is empty and this returns
399
+ # EXEMPT. merge-base moves BACKWARDS in that case, so the range gets wider, not
400
+ # narrower, and no widening recovers it. Inherited from the drift check, which
401
+ # has always failed open the same way for unclaimed files; this extends it to
402
+ # claimed ones. Filed as its own issue.
403
+ absent_from_main() {
404
+ if [ -e "$ENTROPY_MACHINES_ROOT/$1" ]; then return 1; fi
405
+ if [ -n "$2" ]; then return 1; fi
406
+ return 0
407
+ }
408
+
409
+ # --- the interrogation ------------------------------------------------------
410
+ #
411
+ # The question set. FIXED, and every one of them asks for something that cannot
412
+ # be answered with "no" — see the header for why a completion check is worse
413
+ # than nothing. The keys below are what the answers are filed under in the note,
414
+ # in the same order, and the count of keys is the count of answers required.
415
+ INTERROGATION_KEYS='not-run weakest-assertion taken-as-given brief-skipped another-hour'
416
+
417
+ interrogation_questions() {
418
+ echo " 1. What did you NOT run — name the suite, check or manual step — and why not?"
419
+ echo " 2. Which assertion you wrote is the WEAKEST? Name a change to the code"
420
+ echo " under test that would leave it passing anyway."
421
+ echo " 3. What did you take as given without verifying it yourself?"
422
+ echo " 4. What was in the brief that you did not do, or did differently?"
423
+ echo " 5. With another hour, what would you check FIRST, and what do you"
424
+ echo " expect it would turn up?"
425
+ [ -n "$ask" ] && echo " 6. $ask"
426
+ return 0
427
+ }
428
+
429
+ # One line, and no uppercase log verb survives. The shared notes log is parsed
430
+ # by anchored verb, and on 2026-08-26 a HANDOFF note whose free text merely
431
+ # DESCRIBED the log format was read as an entry in it and walled off two files
432
+ # for 40 minutes. An answer is free text written by the party being
433
+ # interrogated, so it is neutralised before it can reach the log: the uppercase
434
+ # forms are what every parser anchors on, and lowercasing them defeats all of
435
+ # them without hiding anything from a human reader.
436
+ flatten_answer() {
437
+ printf '%s' "$1" | tr '\n\r\t' ' ' \
438
+ | sed 's/DISPATCH/dispatch/g; s/HANDOFF/handoff/g; s/INTERROGATION/interrogation/g'
439
+ }
440
+
441
+ # "nothing", as a word, is the answer that makes this whole step ceremonial.
442
+ # One substantive answer is required across the set; which one is the agent's
443
+ # choice, but silence across all of them is not an option.
444
+ is_null_answer() {
445
+ case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d ' .,-')" in
446
+ ""|none|nothing|na|n/a|no|nil|null|nothingtoreport|nothingtoadd|unknown|idk) return 0 ;;
447
+ esac
448
+ return 1
449
+ }
450
+
451
+ # Four states, printed one word each:
452
+ # no-dispatch hand-done work — there was never an agent to question. An
453
+ # UNREADABLE tracker lands here too, and that is a fail-open:
454
+ # the whole record step already degrades that way ("no DISPATCH
455
+ # note found … recording anyway"), and a gate that refuses
456
+ # every landing while the tracker is unreachable would just
457
+ # be routed around with --no-interrogation on every run.
458
+ # not-required the DISPATCH note predates this gate, so the agent was never
459
+ # told it would be questioned. Reported, never refused.
460
+ # recorded an INTERROGATION note follows the LAST DISPATCH for this id.
461
+ # Ordering is load-bearing: a re-dispatch reopens the question.
462
+ # missing the agent was told, and nobody asked.
463
+ # The verb is anchored to the note prefix exactly as bin/dispatch anchors
464
+ # its own, so a note that quotes the log format is not read as an entry in it.
465
+ #
466
+ # READ ONCE, AT THE TOP, THROUGH lib/notes.py — see the block that sets $istate.
467
+ # This used to run its own regex over the rendered note line, which is the same
468
+ # text the seds above scraped and stopped matching for the same reason: against
469
+ # a structured DISPATCH record every state came back "no-dispatch", so an agent
470
+ # dispatched under the interrogation contract was never held to it and nothing
471
+ # said so. The parse lives in one place now, with the same structured-first,
472
+ # rendered-text-fallback grammar every other reader of this log uses.
473
+ #
474
+ # Still a function rather than a bare "$istate" so the two call sites below read
475
+ # as the question they are asking, and so a re-read can be reinstated in one
476
+ # place if a mode is ever added that records and then re-checks in one run.
477
+ interrogation_state() {
478
+ printf '%s\n' "$istate"
479
+ }
480
+
481
+ # EVERY NOTE THIS SCRIPT WRITES IS A STRUCTURED RECORD THAT ALSO CARRIES ITS
482
+ # RENDERED LINE. The verb used to exist only at the head of a free-text string,
483
+ # so lib/tracker-file filed all of it under verb "NOTE" and every structural
484
+ # reader of the log went blind to it: lib/notes.py's live_claims never saw a
485
+ # HANDOFF, so a handed-off issue kept holding its files for the full 24h claim
486
+ # window; bin/dispatch's carry-forward looks up the last HANDOFF record and got
487
+ # nothing; bin/status listed handed-off issues as still in flight. `text` is
488
+ # kept verbatim in the fields so lib/handoff-guard.sh's text branch, and anyone
489
+ # reading the log by eye, see exactly what they saw before.
490
+ #
491
+ # Falls back to writing the plain rendered line if the encoder is unavailable —
492
+ # a note that only the fallback readers can parse still beats no note at all,
493
+ # and the commit-msg gate reads that shape too.
494
+ emit_note() {
495
+ _verb=$1; _text=$2; shift 2
496
+ if _payload=$(python3 "$NOTES_PY" encode --verb "$_verb" --actor "$ENTROPY_ACTOR" \
497
+ --field "text=$_text" "$@" 2>/dev/null) && [ -n "$_payload" ]; then
498
+ printf '%s\n' "$_payload"
499
+ else
500
+ printf '%s\n' "$_text"
501
+ fi
502
+ }
503
+
504
+ # The escape hatch, shared by both gates. Writes the reason into the log as a
505
+ # positive claim and returns 0 so the caller proceeds.
506
+ record_skipped_interrogation() {
507
+ [ -n "$no_interrogation" ] || return 1
508
+ if [ "$dry" -eq 1 ]; then
509
+ echo "handoff: --dry-run — would record: interrogation SKIPPED — $no_interrogation"
510
+ return 0
511
+ fi
512
+ skip_note="INTERROGATION $id — SKIPPED by the lander — why: $(flatten_answer "$no_interrogation")"
513
+ "$ENTROPY_MACHINES_HOME/bin/tracker" remember \
514
+ "$(emit_note INTERROGATION "$skip_note" --field "skipped=$(flatten_answer "$no_interrogation")")" \
515
+ --issue "$id" >/dev/null 2>&1 || true
516
+ echo "handoff: interrogation SKIPPED — recorded on $id as a claim, not an absence:"
517
+ echo " $no_interrogation"
518
+ return 0
519
+ }
520
+
521
+ if [ "$interrogate" -eq 2 ]; then
522
+ interrogation_questions
523
+ exit 0
524
+ fi
525
+
526
+ if [ "$interrogate" -eq 1 ]; then
527
+ [ -n "$id" ] || usage
528
+ echo "handoff: interrogation for $id — send these to the agent while it is still"
529
+ echo " resumable. It holds context nobody else has, and this is the only step"
530
+ echo " that puts a question to it."
531
+ echo
532
+ interrogation_questions
533
+ echo
534
+ echo " Answer every question. If an answer is genuinely 'nothing', say so — but"
535
+ echo " at least one answer must be substantive, so it reads as a claim you made"
536
+ echo " rather than an absence."
537
+ echo
538
+ echo "Then record what it said, in the order above:"
539
+ total=0
540
+ for _k in $INTERROGATION_KEYS; do total=$((total + 1)); done
541
+ [ -n "$ask" ] && total=$((total + 1))
542
+ printf ' bin/handoff %s --record-interrogation \\\n' "$id"
543
+ [ -n "$ask" ] && printf ' --ask "%s" \\\n' "$ask"
544
+ n=1
545
+ while [ "$n" -le "$total" ]; do
546
+ if [ "$n" -eq "$total" ]; then printf ' --answer "<answer %s>"\n' "$n"
547
+ else printf ' --answer "<answer %s>" \\\n' "$n"; fi
548
+ n=$((n + 1))
549
+ done
550
+ echo
551
+ echo " Nothing is recorded until you run that. --dry-run prints the note instead."
552
+ exit 0
553
+ fi
554
+
555
+ if [ "$record_interrogation" -eq 1 ]; then
556
+ [ -n "$id" ] || usage
557
+ nq=0
558
+ for _k in $INTERROGATION_KEYS; do nq=$((nq + 1)); done
559
+ [ -n "$ask" ] && nq=$((nq + 1))
560
+
561
+ if [ "$blank_answer" -eq 1 ]; then
562
+ echo "handoff: REFUSED — an --answer is empty." >&2
563
+ echo " Every question gets an answer. 'nothing' is an acceptable answer and an" >&2
564
+ echo " empty string is not: one is a claim the agent made, the other is the" >&2
565
+ echo " absence this step exists to remove." >&2
566
+ exit 1
567
+ fi
568
+ if [ "$n_answers" -ne "$nq" ]; then
569
+ echo "handoff: REFUSED — $n_answers answer(s) for $nq question(s)." >&2
570
+ echo " Pass one --answer per question, in the order they were printed:" >&2
571
+ interrogation_questions >&2
572
+ exit 1
573
+ fi
574
+
575
+ substantive=0
576
+ old_ifs=$IFS
577
+ IFS='
578
+ '
579
+ for a in $answers; do
580
+ is_null_answer "$a" || substantive=1
581
+ done
582
+ IFS=$old_ifs
583
+ if [ "$substantive" -eq 0 ]; then
584
+ echo "handoff: REFUSED — every answer is a variant of 'nothing'." >&2
585
+ echo " At least one has to carry something. An agent that ran everything, took" >&2
586
+ echo " nothing on trust, followed the brief exactly and has nothing left to" >&2
587
+ echo " check is describing work that has never happened here — both recorded" >&2
588
+ echo " half-changes were reported exactly that way." >&2
589
+ exit 1
590
+ fi
591
+
592
+ note="INTERROGATION $id"
593
+ sep=" — "
594
+ i=0
595
+ old_ifs=$IFS
596
+ IFS='
597
+ '
598
+ for a in $answers; do
599
+ i=$((i + 1))
600
+ if [ -n "$ask" ] && [ "$i" -eq "$nq" ]; then k="lander-asked"; else
601
+ k=$(echo $INTERROGATION_KEYS | cut -d' ' -f"$i")
602
+ fi
603
+ note="${note}${sep}${k}: $(flatten_answer "$a")"
604
+ sep=" | "
605
+ done
606
+ IFS=$old_ifs
607
+ [ -n "$ask" ] && note="$note | lander-question: $(flatten_answer "$ask")"
608
+
609
+ if [ "$dry" -eq 1 ]; then
610
+ echo "handoff: --dry-run, nothing written. Would record on $id:"
611
+ echo " $note"
612
+ exit 0
613
+ fi
614
+ "$ENTROPY_MACHINES_HOME/bin/tracker" remember "$(emit_note INTERROGATION "$note")" \
615
+ --issue "$id" >/dev/null
616
+ echo "handoff: interrogation recorded on $id ($nq answer(s))."
617
+ echo " --lift and the record step will now run for this id."
618
+ exit 0
619
+ fi
620
+
621
+ # Derived, not listed — see the header. Prints repo-relative paths, plus
622
+ # "!ROT <text>" lines when a generator stops being readable.
623
+ gen_set() {
624
+ # DECLARED, NOT SCRAPED. This used to read the project's generator SOURCE and
625
+ # regex out its output paths. That worked for exactly one project's
626
+ # conventions and silently produced nothing for any other — and "produced
627
+ # nothing" here reads identically to "no generated files were touched",
628
+ # which is the failure mode this check exists to catch.
629
+ #
630
+ # So the list is declared in config.json as `generate.outputs`. If a project
631
+ # declares none, this prints one !ROT line and the caller degrades loudly
632
+ # rather than quietly passing.
633
+ python3 - "$ENTROPY_MACHINES_HOME" "$ENTROPY_MACHINES_ROOT" <<'PYGEN'
634
+ import os, sys
635
+ sys.path.insert(0, os.path.join(sys.argv[1], 'lib'))
636
+ try:
637
+ from config import load_config
638
+ cfg = load_config(sys.argv[2])
639
+ except Exception as e:
640
+ print('!ROT config.json could not be read (%s)' % e)
641
+ raise SystemExit(0)
642
+
643
+ gen = cfg.get('generate') or {}
644
+ outputs = gen.get('outputs') or []
645
+ if not gen.get('cmd'):
646
+ # No generate step at all is a legitimate project shape, not a fault.
647
+ raise SystemExit(0)
648
+ if not outputs:
649
+ print('!ROT generate.cmd is set but generate.outputs is empty — '
650
+ 'this check cannot tell a stale generated file from an edited one')
651
+ raise SystemExit(0)
652
+ for f in sorted(outputs):
653
+ print(f)
654
+ PYGEN
655
+ }
656
+
657
+ # --- worktree drift scan ----------------------------------------------------
658
+ #
659
+ # Everything here resolves refs with `git -C "$ENTROPY_MACHINES_ROOT"` and never against
660
+ # the ambient cwd. A shell whose cwd has wandered into a worktree — or, before
661
+ # the one-root collapse, into a nested harness clone — resolves refs against a
662
+ # different tree than the one being folded into; that took out three dispatches
663
+ # on 2026-08-17. Pinning the -C is what makes the answer independent of cwd.
664
+ wt=""; wt_head=""; base=""
665
+ scan_worktree() {
666
+ wt=$(cd "$from" 2>/dev/null && pwd -P) || wt=""
667
+ if [ -z "$wt" ]; then echo "handoff: no such worktree: $from" >&2; exit 1; fi
668
+ if [ "$wt" = "$ENTROPY_MACHINES_ROOT" ]; then
669
+ echo "handoff: REFUSED — --from is this checkout ($ENTROPY_MACHINES_ROOT), not a worker's worktree." >&2
670
+ exit 1
671
+ fi
672
+ # Same repository, established through the object store rather than the path.
673
+ # A path-prefix test would accept a worktree of some OTHER repo that happens
674
+ # to sit under this one, and it would be scanned against refs that mean
675
+ # nothing here.
676
+ #
677
+ # $a is just $ENTROPY_MACHINES_ROOT — it was already resolved this same way
678
+ # (parent of `git rev-parse --git-common-dir`) at the top of the script.
679
+ # $b has to be resolved the identical way from $wt, not compared against
680
+ # the bare git-common-dir itself: that is one directory level too deep
681
+ # (the .git dir, not its parent) and made every worktree of this very
682
+ # repository read as "not a worktree of this repository."
683
+ a=$ENTROPY_MACHINES_ROOT
684
+ b_gitdir=$(cd "$wt" && git rev-parse --git-common-dir 2>/dev/null) || b_gitdir=""
685
+ b=""
686
+ if [ -n "$b_gitdir" ]; then
687
+ case "$b_gitdir" in
688
+ /*) ;;
689
+ *) b_gitdir="$wt/$b_gitdir" ;;
690
+ esac
691
+ b=$(cd "$b_gitdir/.." 2>/dev/null && pwd -P) || b=""
692
+ fi
693
+ if [ "$a" != "$b" ]; then
694
+ echo "handoff: REFUSED — $wt is not a worktree of this repository." >&2
695
+ echo " its git dir: ${b_gitdir:-<none>}" >&2
696
+ echo " this one: $a" >&2
697
+ exit 1
698
+ fi
699
+
700
+ wt_head=$(git -C "$wt" rev-parse HEAD)
701
+ # Agents do not commit, so a worktree's HEAD normally IS the commit it
702
+ # branched from (worktree.baseRef is "head"). merge-base covers the agent that
703
+ # committed anyway, and resolves in the main repo's object store.
704
+ base=$(git -C "$ENTROPY_MACHINES_ROOT" merge-base "$wt_head" HEAD 2>/dev/null || echo "$wt_head")
705
+
706
+ tmp=$(mktemp -d)
707
+ : > "$tmp/copy"; : > "$tmp/same"; : > "$tmp/deleted"; : > "$tmp/outscope"
708
+ : > "$tmp/collide"; : > "$tmp/drift_src"; : > "$tmp/drift_gen"; : > "$tmp/drifted"
709
+ : > "$tmp/claimed"; : > "$tmp/advisory"; : > "$tmp/newclaimed"
710
+ gen_set > "$tmp/gen" || true
711
+
712
+ # What the worker changed: its uncommitted edits (the normal case), plus
713
+ # anything it committed against the rules.
714
+ {
715
+ # -uall, not the default: plain --porcelain collapses a new directory to a
716
+ # single "?? src/new/" line, and a lander cannot cp a path that names four
717
+ # files. Every untracked file is listed individually.
718
+ git -C "$wt" status --porcelain -uall | awk '
719
+ {
720
+ p = substr($0, 4); x = substr($0, 1, 1); y = substr($0, 2, 1)
721
+ i = index(p, " -> ")
722
+ if (i > 0) { print "DEL " substr(p, 1, i - 1); print "MOD " substr(p, i + 4); next }
723
+ if (x == "?") { print "ADD " p; next }
724
+ if (x == "D" || y == "D") { print "DEL " p; next }
725
+ print "MOD " p
726
+ }'
727
+ git -C "$ENTROPY_MACHINES_ROOT" diff --name-status "$base" "$wt_head" | awk '
728
+ {
729
+ if ($1 ~ /^R/) { print "DEL " $2; print "MOD " $3; next }
730
+ if ($1 == "D") { print "DEL " $2; next }
731
+ print "MOD " $2
732
+ }'
733
+ } | sort -u > "$tmp/changes"
734
+
735
+ while read -r kind path; do
736
+ [ -n "$path" ] || continue
737
+ # The agent's own note file is gitignored and is not part of its change.
738
+ case "$path" in HANDOFF.md) continue ;; esac
739
+
740
+ # Drift is recorded for EVERY changed file, before any other verdict, so
741
+ # the record step's stale-copy gate sees the whole picture even for a file
742
+ # --lift itself would have refused for another reason.
743
+ moved=$(git -C "$ENTROPY_MACHINES_ROOT" log --oneline "$base..HEAD" -- "$path" || true)
744
+ if [ -n "$moved" ]; then
745
+ echo "$path" >> "$tmp/drifted"
746
+ printf '%s\n' "$moved" | sed 's/^/ /' > "$tmp/log.$(echo "$path" | tr / _)"
747
+ fi
748
+
749
+ # Denylist first when it is known, allowlist when it is not (see the three
750
+ # states above). A claimed file is refused and never copied; a file merely
751
+ # outside the dispatcher's prediction is copied and REPORTED, because the
752
+ # prediction was made before anyone read the code.
753
+ if [ "$deny_known" -eq 1 ]; then
754
+ if is_claimed "$path"; then
755
+ # Absent from this checkout AND untouched on main since the worker
756
+ # branched: a new file, nothing to clobber. Reported, not refused.
757
+ if absent_from_main "$path" "$moved"; then
758
+ echo "$path" >> "$tmp/newclaimed"
759
+ else
760
+ echo "$path" >> "$tmp/claimed"; continue
761
+ fi
762
+ else
763
+ in_scope "$path" || echo "$path" >> "$tmp/advisory"
764
+ fi
765
+ else
766
+ if ! in_scope "$path"; then echo "$path" >> "$tmp/outscope"; continue; fi
767
+ fi
768
+ if [ "$kind" = "DEL" ]; then echo "$path" >> "$tmp/deleted"; continue; fi
769
+
770
+ if [ -n "$moved" ]; then
771
+ if grep -Fxq "$path" "$tmp/gen"; then echo "$path" >> "$tmp/drift_gen"
772
+ else echo "$path" >> "$tmp/drift_src"; fi
773
+ continue
774
+ fi
775
+
776
+ if [ "$kind" = "ADD" ] && [ -e "$ENTROPY_MACHINES_ROOT/$path" ]; then
777
+ if cmp -s "$wt/$path" "$ENTROPY_MACHINES_ROOT/$path"; then echo "$path" >> "$tmp/same"
778
+ else echo "$path" >> "$tmp/collide"; fi
779
+ continue
780
+ fi
781
+ if [ -e "$ENTROPY_MACHINES_ROOT/$path" ] && cmp -s "$wt/$path" "$ENTROPY_MACHINES_ROOT/$path"; then
782
+ echo "$path" >> "$tmp/same"; continue
783
+ fi
784
+ echo "$path" >> "$tmp/copy"
785
+ done < "$tmp/changes"
786
+ }
787
+
788
+ # --- --from: read the agent's HANDOFF.md ------------------------------------
789
+ #
790
+ # The agent writes four `key: one line` fields in its worktree. This lifts them
791
+ # so the lander edits rather than reinvents, and prints exactly what was lifted
792
+ # before anything is recorded — the agent's words become the lander's statement
793
+ # only after the lander has seen them.
794
+ #
795
+ # --verified is NEVER lifted, no matter what the file says. It is the one field
796
+ # that asserts the lander re-ran the work themselves (dispatch rule 5), so a
797
+ # value read out of the agent's own file would be precisely the relayed claim
798
+ # the check below refuses.
799
+ #
800
+ # Only the FIRST LINE of each field is taken, and a longer field is reported as
801
+ # truncated. Brevity is the format's job: four lines a lander will actually
802
+ # read beat a page nobody does, and a cap that trims is better than a refusal
803
+ # that blocks a landing over an agent's verbosity.
804
+ if [ -n "$from" ]; then
805
+ hf="$from/HANDOFF.md"
806
+ if [ -f "$hf" ]; then
807
+ field() { sed -n "s/^[[:space:]]*$1:[[:space:]]*//p" "$hf" | head -1; }
808
+ extra=$(grep -cE '^[[:space:]]*(changed|found|assumed|next):' "$hf" || true)
809
+ echo "handoff: lifted from $hf —"
810
+ for k in changed found assumed next; do
811
+ v=$(field "$k"); [ -n "$v" ] && echo " $k: $v"
812
+ done
813
+ [ "$extra" -gt 4 ] && echo " (note: $extra field lines, only the first of each was taken)"
814
+ case "$(field changed)" in "") ;; *) [ -z "$changed" ] && changed=$(field changed) ;; esac
815
+ case "$(field found)" in ""|none|None|NONE) ;; *) [ -z "$found" ] && found=$(field found) ;; esac
816
+ case "$(field assumed)" in ""|none|None|NONE) ;; *) [ -z "$assumed" ] && assumed=$(field assumed) ;; esac
817
+ case "$(field next)" in ""|none|None|NONE) ;; *) [ -z "$next_note" ] && next_note=$(field next) ;; esac
818
+ echo
819
+ elif [ "$lift" -eq 1 ]; then
820
+ # --lift is about the files, not the note. A worker that skipped its
821
+ # HANDOFF.md still has a diff worth moving, and the record step refuses the
822
+ # missing --changed on its own.
823
+ echo "handoff: no HANDOFF.md in $from — lifting files only."
824
+ echo
825
+ else
826
+ echo "handoff: no HANDOFF.md in $from" >&2; exit 1
827
+ fi
828
+ fi
829
+
830
+ # --- --lift: move the worker's files in, refusing anything main has moved ---
831
+ if [ "$lift" -eq 1 ]; then
832
+ [ -n "$id" ] && [ -n "$from" ] || usage
833
+
834
+ # THE LIFT-SIDE INTERROGATION GATE. Refuses before anything is copied, so a
835
+ # refusal leaves this checkout untouched.
836
+ #
837
+ # NARROWED TO WORKERS THAT LEFT A HANDOFF.md, deliberately and not silently.
838
+ # A --lift with no HANDOFF.md is already a different path here ("lifting files
839
+ # only"): there is no report to question, and the id may not belong to a live
840
+ # agent at all. The hole that opens — delete HANDOFF.md and lift freely —
841
+ # closes one step later at the record step, which is gated unconditionally and
842
+ # which the commit-msg hook makes unavoidable. This narrowing also keeps the
843
+ # four --lift fixtures in tests/core/dispatch-scope-denylist.test.ts green;
844
+ # they build a worktree with no HANDOFF.md, and that file was claimed by
845
+ # another issue when this landed.
846
+ if [ "$(interrogation_state)" = "missing" ]; then
847
+ if ! record_skipped_interrogation; then
848
+ echo "handoff: REFUSED — $id was dispatched with an interrogation required and" >&2
849
+ echo " none is recorded. Nothing has been copied." >&2
850
+ echo " The agent is resumable and holds context nobody else does. Ask it:" >&2
851
+ echo " bin/handoff $id --interrogate" >&2
852
+ echo " then record what it said:" >&2
853
+ echo " bin/handoff $id --record-interrogation --answer \"...\"" >&2
854
+ echo " If you cannot reach it — the session is gone, the agent crashed —" >&2
855
+ echo " say so and it goes on the record as a claim rather than an absence:" >&2
856
+ echo " --no-interrogation \"<why nobody could ask it>\"" >&2
857
+ exit 1
858
+ fi
859
+ echo
860
+ fi
861
+
862
+ scan_worktree
863
+
864
+ rot=$(grep '^!ROT' "$tmp/gen" || true)
865
+ if [ -n "$rot" ]; then
866
+ echo "handoff: WARNING — the generated-file list could not be fully derived:" >&2
867
+ printf '%s\n' "$rot" | sed 's/^!ROT / /' >&2
868
+ echo " A generated file missing from that list is reported below as a SOURCE" >&2
869
+ echo " file: still refused, but with the wrong remedy. Fix the derivation in" >&2
870
+ echo " bin/handoff (gen_set) before trusting a SOURCE verdict." >&2
871
+ echo >&2
872
+ fi
873
+
874
+ echo "handoff: --lift $id"
875
+ echo " from $wt"
876
+ echo " into $ENTROPY_MACHINES_ROOT"
877
+ echo " worker branched at $(git -C "$ENTROPY_MACHINES_ROOT" log -1 --format='%h %s' "$base")"
878
+ echo " HEAD here is $(git -C "$ENTROPY_MACHINES_ROOT" log -1 --format='%h %s' HEAD)"
879
+ echo " $(git -C "$ENTROPY_MACHINES_ROOT" rev-list --count "$base..HEAD") commit(s) landed since the worker branched"
880
+ echo
881
+
882
+ n=0
883
+ while read -r p; do
884
+ [ -n "$p" ] || continue
885
+ if [ "$dry" -eq 1 ]; then
886
+ echo " would copy $p"
887
+ else
888
+ mkdir -p "$ENTROPY_MACHINES_ROOT/$(dirname "$p")"
889
+ cp "$wt/$p" "$ENTROPY_MACHINES_ROOT/$p"
890
+ echo " copied $p"
891
+ fi
892
+ n=$((n + 1))
893
+ done < "$tmp/copy"
894
+ while read -r p; do
895
+ [ -n "$p" ] || continue
896
+ echo " identical $p (this tree already holds the worker's bytes)"
897
+ done < "$tmp/same"
898
+ [ "$n" -eq 0 ] && echo " (no file was clean to copy)"
899
+ echo
900
+
901
+ refused=0
902
+
903
+ if [ -s "$tmp/drift_gen" ]; then
904
+ refused=1
905
+ echo "handoff: REFUSED — GENERATED file(s) moved on main after the worker branched." >&2
906
+ echo " Copying a generated file is never the fix, drifted or not:" >&2
907
+ while read -r p; do
908
+ [ -n "$p" ] || continue
909
+ echo " $p" >&2
910
+ cat "$tmp/log.$(echo "$p" | tr / _)" >&2
911
+ done < "$tmp/drift_gen"
912
+ echo " The worker built its bundle inside its own tree, which predates those" >&2
913
+ echo " commits, so the bundle it produced is MISSING them. Land the SOURCE" >&2
914
+ echo " files first, then regenerate here:" >&2
915
+ echo " npm run gen" >&2
916
+ echo " git diff -- <file> # confirm the fresh bundle still carries what" >&2
917
+ echo " # landed above, not only the worker's change" >&2
918
+ echo " Do not copy the worker's bundle and do not force it: that is the" >&2
919
+ echo " recorded near-miss exactly: a bundle built before a fix landed." >&2
920
+ echo >&2
921
+ fi
922
+
923
+ if [ -s "$tmp/drift_src" ]; then
924
+ refused=1
925
+ echo "handoff: REFUSED — source file(s) moved on main after the worker branched:" >&2
926
+ while read -r p; do
927
+ [ -n "$p" ] || continue
928
+ echo " $p" >&2
929
+ cat "$tmp/log.$(echo "$p" | tr / _)" >&2
930
+ done < "$tmp/drift_src"
931
+ echo " The worker edited the version at $(git -C "$ENTROPY_MACHINES_ROOT" rev-parse --short "$base"). Copying its copy reverts" >&2
932
+ echo " those commits — silently, and the test that covered them goes red later" >&2
933
+ echo " somewhere else (2026-08-22: select_option, cli-tool-reachability 2/2)." >&2
934
+ echo " This needs a merge or a re-dispatch, not a copy. Read both halves:" >&2
935
+ echo " git -C $wt diff -- <file>" >&2
936
+ echo " git -C $ENTROPY_MACHINES_ROOT diff $(git -C "$ENTROPY_MACHINES_ROOT" rev-parse --short "$base")..HEAD -- <file>" >&2
937
+ echo " then apply the worker's change on top by hand, or re-dispatch the issue" >&2
938
+ echo " from current HEAD." >&2
939
+ echo >&2
940
+ fi
941
+
942
+ if [ -s "$tmp/deleted" ]; then
943
+ refused=1
944
+ echo "handoff: the worker DELETED file(s). --lift never deletes anything:" >&2
945
+ sed 's/^/ /' "$tmp/deleted" >&2
946
+ echo " If the deletion is part of the change, do it yourself: git rm <path>" >&2
947
+ echo >&2
948
+ fi
949
+
950
+ if [ -s "$tmp/collide" ]; then
951
+ refused=1
952
+ echo "handoff: the worker ADDED file(s) that already exist here with different" >&2
953
+ echo " content — someone else created the same path:" >&2
954
+ sed 's/^/ /' "$tmp/collide" >&2
955
+ echo " Diff them and decide; --lift will not overwrite an unrelated file." >&2
956
+ echo >&2
957
+ fi
958
+
959
+ # THE CLAIMED-FILE REFUSAL. Exactly as hard as the out-of-allowlist refusal it
960
+ # replaces — same exit 1, same never-copied, same "read the diff and do it by
961
+ # hand if you mean it". What changed is only WHICH files it names: the ones
962
+ # another issue was actually holding, instead of every file the dispatcher
963
+ # failed to predict.
964
+ if [ -s "$tmp/claimed" ]; then
965
+ refused=1
966
+ echo "handoff: REFUSED — the worker wrote file(s) CLAIMED by another issue when" >&2
967
+ echo " it was dispatched:" >&2
968
+ sed 's/^/ /' "$tmp/claimed" >&2
969
+ echo " recorded denylist: $deny" >&2
970
+ echo " Another agent was editing these this round and its edits are invisible" >&2
971
+ echo " from the worker's worktree, so copying is a straight clobber. Land or" >&2
972
+ echo " hand off that agent first, then read both diffs and merge by hand." >&2
973
+ echo " The worker was told to STOP at this boundary rather than write it, so" >&2
974
+ echo " read its HANDOFF.md found: line before you decide anything." >&2
975
+ echo >&2
976
+ fi
977
+
978
+ # NOT a refusal — the general form of the exemption, and the reason the two
979
+ # path-specific patches before it kept not being enough. See absent_from_main.
980
+ # The lander still reads the diff, exactly as it does for a file outside the
981
+ # advisory --files; what it does not do is copy the file by hand.
982
+ if [ -s "$tmp/newclaimed" ]; then
983
+ echo "handoff: note — file(s) on the recorded denylist that DO NOT EXIST here."
984
+ echo " A path absent from this checkout cannot be a clobber of another agent's"
985
+ echo " edits — there is nothing there to clobber — so these were copied, not"
986
+ echo " refused. Read these diffs on purpose:"
987
+ sed 's/^/ /' "$tmp/newclaimed"
988
+ echo " Main has not touched them since the worker branched either; one that had"
989
+ echo " landed here in the meantime is refused above as a claimed file."
990
+ echo
991
+ fi
992
+
993
+ # NOT a refusal. A file outside --files that nobody claimed is the case this
994
+ # whole inversion exists for: the dispatcher predicted the scope before
995
+ # reading the code, and on 2026-08-26 that prediction bounced a worker's own
996
+ # brand-new changelog.d fragment. Copied, and printed so the lander reads it.
997
+ if [ -s "$tmp/advisory" ]; then
998
+ echo "handoff: note — file(s) outside the advisory --files, and claimed by"
999
+ echo " nobody. Copied; read these diffs on purpose:"
1000
+ sed 's/^/ /' "$tmp/advisory"
1001
+ echo " advisory --files: $scope"
1002
+ echo
1003
+ fi
1004
+
1005
+ if [ -s "$tmp/outscope" ]; then
1006
+ refused=1
1007
+ echo "handoff: file(s) the worker changed are OUTSIDE the scope dispatch recorded:" >&2
1008
+ sed 's/^/ /' "$tmp/outscope" >&2
1009
+ echo " recorded scope: $scope" >&2
1010
+ echo " This dispatch note records no denylist, so the scope is enforced as an" >&2
1011
+ echo " allowlist — the pre-2026-08-27 behaviour, unchanged." >&2
1012
+ echo " A file outside the declared scope may belong to another agent running" >&2
1013
+ echo " this round (dispatch rule 3). Read the diff and copy it by hand if you" >&2
1014
+ echo " mean it — and check whether the other agent touched it too." >&2
1015
+ echo >&2
1016
+ fi
1017
+
1018
+ if [ "$refused" -eq 1 ]; then
1019
+ echo "handoff: --lift copied what was clean and refused the rest. Nothing recorded." >&2
1020
+ exit 1
1021
+ fi
1022
+ echo "handoff: --lift done. Nothing recorded — verify in THIS tree, then:"
1023
+ echo " bin/handoff $id --from $from --verified \"what YOU re-ran\""
1024
+ exit 0
1025
+ fi
1026
+
1027
+ [ -n "$id" ] && [ -n "$changed" ] && [ -n "$verified" ] || usage
1028
+
1029
+ # --verified must be a first-person claim, not a relay of the agent's report.
1030
+ case "$(printf '%s' "$verified" | tr '[:upper:]' '[:lower:]')" in
1031
+ *"agent report"*|*"agent says"*|*"agent said"*|*"agent claims"*|*"per the agent"*|\
1032
+ *"as reported"*|*"it reports"*|*"they report"*|*"subagent report"*|*"trust"*)
1033
+ echo "handoff: REFUSED — --verified relays the agent instead of stating what YOU ran:" >&2
1034
+ echo " $verified" >&2
1035
+ echo " Dispatch rule 5 is that the landing session re-runs the claim. Write the" >&2
1036
+ echo " command you ran and what it printed, or re-run it now if you have not." >&2
1037
+ exit 1 ;;
1038
+ esac
1039
+
1040
+ # THE RECORD-STEP INTERROGATION GATE — the one that cannot be walked around.
1041
+ # --lift can be replaced by `cp`, but nothing can replace this note: the
1042
+ # commit-msg hook refuses a commit naming a dispatched id until it exists. So an
1043
+ # agent dispatched under the interrogation contract cannot reach a commit
1044
+ # without someone having put the questions to it, or having said in writing why
1045
+ # nobody could.
1046
+ istate=$(interrogation_state)
1047
+ case "$istate" in
1048
+ missing)
1049
+ if ! record_skipped_interrogation; then
1050
+ echo "handoff: REFUSED — $id was dispatched with an interrogation required and" >&2
1051
+ echo " none is recorded. Nothing has been written." >&2
1052
+ echo " A HANDOFF.md is a monologue by the party with the least incentive to" >&2
1053
+ echo " report a gap. Both recorded half-changes were reported as successes." >&2
1054
+ echo " Ask the agent while it is still resumable:" >&2
1055
+ echo " bin/handoff $id --interrogate" >&2
1056
+ echo " bin/handoff $id --record-interrogation --answer \"...\"" >&2
1057
+ echo " Or say why nobody could, and it lands as a claim:" >&2
1058
+ echo " --no-interrogation \"<why>\"" >&2
1059
+ exit 1
1060
+ fi ;;
1061
+ not-required)
1062
+ # The agent was never told it would be questioned, so it is not held to it.
1063
+ echo "handoff: note — the dispatch record for $id predates the interrogation"
1064
+ echo " step, so nothing was asked of this agent. Nothing is refused over it." ;;
1065
+ esac
1066
+
1067
+ if [ -z "$found" ] && [ -z "$next_note" ] && [ "$clean" -ne 1 ]; then
1068
+ echo "handoff: REFUSED — no --found and no --next." >&2
1069
+ echo " These are the two fields worth salvaging and the two that get dropped." >&2
1070
+ echo " If the agent genuinely surfaced nothing beyond its diff, say so with" >&2
1071
+ echo " --clean so it reads as a claim rather than an omission." >&2
1072
+ exit 1
1073
+ fi
1074
+
1075
+ # THE STALE-COPY GATE. Whether or not --lift did the copying, this is the last
1076
+ # moment before the commit. For every file the worker changed that main has
1077
+ # ALSO moved since the worker branched, this tree should not hold the worker's
1078
+ # bytes verbatim — a real merge, or a regeneration, contains what landed too.
1079
+ # Byte-identical means those commits were copied away.
1080
+ if [ -n "$from" ]; then
1081
+ scan_worktree
1082
+ stale=""
1083
+ if [ -s "$tmp/drifted" ]; then
1084
+ while read -r p; do
1085
+ [ -n "$p" ] || continue
1086
+ if [ -e "$ENTROPY_MACHINES_ROOT/$p" ] && cmp -s "$wt/$p" "$ENTROPY_MACHINES_ROOT/$p"; then stale="$stale $p"; fi
1087
+ done < "$tmp/drifted"
1088
+ fi
1089
+ if [ -n "$stale" ] && [ -z "$drift_reviewed" ]; then
1090
+ echo "handoff: REFUSED — this tree holds the worker's version of file(s) that" >&2
1091
+ echo " main moved after the worker branched, byte for byte:" >&2
1092
+ for p in $stale; do
1093
+ echo " $p" >&2
1094
+ cat "$tmp/log.$(echo "$p" | tr / _)" >&2
1095
+ done
1096
+ echo " Those commits are not in what you are about to commit. Redo the copy as" >&2
1097
+ echo " a merge — or, for a generated file, land the source and run \`npm run gen\`" >&2
1098
+ echo " here — then rerun this." >&2
1099
+ echo " If you already read both diffs and the worker's version IS the right" >&2
1100
+ echo " result, or you already committed these files (which is what this is" >&2
1101
+ echo " seeing), say so and it goes on the record:" >&2
1102
+ echo " --drift-reviewed \"<why the worker's version is right>\"" >&2
1103
+ exit 1
1104
+ fi
1105
+ if [ -s "$tmp/drifted" ] && [ -z "$stale" ]; then
1106
+ echo "handoff: note — main moved these while the worker held them, and this tree"
1107
+ echo " does NOT hold the worker's copy of them (merged, regenerated or skipped):"
1108
+ sed 's/^/ /' "$tmp/drifted"
1109
+ echo
1110
+ fi
1111
+ fi
1112
+
1113
+ if [ "$dispatch_found" -eq 0 ]; then
1114
+ echo "handoff: no DISPATCH note found for $id — recording anyway (hand-done work?)."
1115
+ fi
1116
+
1117
+ note="HANDOFF $id — changed: $changed"
1118
+ [ -n "$found" ] && note="$note | found-not-fixed: $found"
1119
+ [ -n "$assumed" ] && note="$note | assumed: $assumed"
1120
+ [ -n "$next_note" ] && note="$note | next-agent: $next_note"
1121
+ [ "$clean" -eq 1 ] && [ -z "$found" ] && [ -z "$next_note" ] && note="$note | found-not-fixed: none claimed (--clean)"
1122
+ note="$note | verified: $verified"
1123
+ [ -n "$drift_reviewed" ] && note="$note | drift-reviewed: $drift_reviewed"
1124
+ # So the log says, per landing, whether anyone questioned the agent.
1125
+ [ "$istate" = "recorded" ] && note="$note | interrogation: recorded"
1126
+ [ -n "$no_interrogation" ] && note="$note | interrogation: skipped"
1127
+ [ -n "$scope" ] && note="$note | scope released: $scope"
1128
+
1129
+ if [ "$dry" -eq 1 ]; then
1130
+ echo "handoff: --dry-run, nothing written. Would record on $id:"
1131
+ echo " $note"
1132
+ exit 0
1133
+ fi
1134
+
1135
+ set -- --field "changed=$changed" --field "verified=$verified"
1136
+ [ -n "$found" ] && set -- "$@" --field "found=$found"
1137
+ [ -n "$assumed" ] && set -- "$@" --field "assumed=$assumed"
1138
+ [ -n "$next_note" ] && set -- "$@" --field "next=$next_note"
1139
+ [ -n "$scope" ] && set -- "$@" --field "scope=$scope"
1140
+ # `set -e` and an AND-list whose test is false: harmless above because each is
1141
+ # followed by another statement, and deliberately not the LAST statement in this
1142
+ # block — a false test there ends the script before the note is written, which is
1143
+ # how three gates in this repo were silently lost.
1144
+ true
1145
+
1146
+ "$ENTROPY_MACHINES_HOME/bin/tracker" remember "$(emit_note HANDOFF "$note" "$@")" \
1147
+ --issue "$id" >/dev/null
1148
+
1149
+ echo "handoff: recorded on $id."
1150
+ [ -n "$scope" ] && echo " scope released: $scope"
1151
+ echo " The commit-msg guard for $id is now satisfied."