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.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- 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."
|