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
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env bash
2
+ # handoff-guard — a commit that lands a DISPATCHED issue must carry a handoff
3
+ # record for it.
4
+ #
5
+ # lib/handoff-guard.sh --message .git/COMMIT_EDITMSG # commit-msg hook
6
+ # lib/handoff-guard.sh --commit <sha> # audit one commit
7
+ # lib/handoff-guard.sh --range A..B # audit a range
8
+ #
9
+ # LOCAL-ONLY BY DESIGN, AND THAT IS NOT THE WEAK CASE HERE. changelog-guard.sh
10
+ # is mirrored by a CI job because the rule it enforces has to hold for anything
11
+ # that reaches the remote. This one does not: agents are dispatched, landed and
12
+ # verified locally by design, so the machine doing the landing is
13
+ # always the machine with the hook, and a handoff that never left a laptop has
14
+ # still done its whole job — the next session reads it out of the same log.
15
+ #
16
+ # A CI job would also be impossible if we wanted one. The record lives in the
17
+ # tracker's note log, which is commonly gitignored in its
18
+ # entirety (.gitignore:37), so a runner checks out a tree with no log, reads
19
+ # nothing, and passes everything. Do not "fix" that by moving the record
20
+ # somewhere tracked: the log is where agents and sessions already read, and
21
+ # splitting it to satisfy a check nobody runs would cost the thing that makes
22
+ # it useful.
23
+ #
24
+ # The real local failure mode is not CI's absence — it is a checkout that never
25
+ # ran `npm run hooks:install` and therefore has no gate while looking exactly
26
+ # like one that does. bin/dispatch refuses to brief an agent from such a
27
+ # checkout, which puts the check immediately before the only activity this gate
28
+ # protects. --range and --commit audit history by hand.
29
+ #
30
+ # WHY commit-msg AND NOT pre-commit. The gate keys on the issue id in the
31
+ # commit message, and pre-commit runs before a message exists. changelog-guard
32
+ # can live in pre-commit because it only reads the index; this cannot.
33
+ #
34
+ # WHAT IT GATES, AND WHAT IT DELIBERATELY DOES NOT. It fires only for an id
35
+ # that bin/dispatch actually recorded a DISPATCH note for. A commit naming
36
+ # an issue you did yourself, by hand, passes untouched — there was no agent, so
37
+ # there is nothing to salvage from a worktree. This keeps the gate off the
38
+ # large majority of commits and pointed at the one case that loses knowledge.
39
+ #
40
+ # GRANDFATHERING, WHICH IS OFF BY DEFAULT AND EXISTS FOR ADOPTERS. When this
41
+ # gate first landed in its home repo, several agents were already in flight,
42
+ # briefed under the old protocol and unable to know about the new one.
43
+ # Refusing their landings would have stranded other sessions' work for a rule
44
+ # written after they started.
45
+ #
46
+ # The fix generalises, so it is kept as an option rather than a baked-in date:
47
+ # set `guards.handoffCutoff` (an ISO-8601 timestamp) in config.json and a
48
+ # dispatch recorded BEFORE it warns and passes, while one recorded after
49
+ # refuses. The exemption then expires BY ITSELF as those dispatches land —
50
+ # there is no flag anyone must remember to flip, and no window in which the
51
+ # rule is off for new work. That self-expiry is the part worth copying.
52
+ #
53
+ # A FRESH INSTALL WANTS NO CUTOFF AT ALL, and that is the default: with the
54
+ # key absent every dispatch is judged by the rule. Only set it if you are
55
+ # turning this gate on in a repo that already has agents mid-flight.
56
+ #
57
+ # ESCAPE HATCH, in the two forms the callers need (commit-msg has the message
58
+ # but CI has only history):
59
+ #
60
+ # SKIP_HANDOFF=1 git commit -m "fix(x): partial landing (i-foo) [skip handoff]"
61
+ #
62
+ # Use both or it passes locally and fails in CI. `--no-verify` is not a hatch:
63
+ # it skips the hook and leaves nothing behind.
64
+ #
65
+ # ===========================================================================
66
+ # THE SECOND CHECK — an ID-LESS COMMIT INTO AN OPEN DISPATCH'S SCOPE
67
+ #
68
+ # ===========================================================================
69
+ #
70
+ # THE HOLE. Everything above keys on an issue id, recovered from the message or
71
+ # from a changelog.d fragment's `issue:` line. changelog-guard.sh only requires
72
+ # a fragment for the project's own code paths. So a commit that touches only
73
+ # tooling and tests, names no id and adds no fragment has NO id to recover,
74
+ # and this guard has nothing to refuse — the whole protocol is off. Every
75
+ # commit in the 2026-08-26/27 harness sprint was that shape, which is where
76
+ # dispatch discipline matters most.
77
+ #
78
+ # THE RULE, AND WHY IT IS THIS NARROW. When a commit names NO id at all and
79
+ # touches a path that a dispatch is HOLDING at that moment (a DISPATCH note
80
+ # with no later HANDOFF, inside the same 24h claim window bin/dispatch
81
+ # uses), it is refused. Naming the id is enough to satisfy it — and that hands
82
+ # the commit to the id-keyed check above, which then wants the handoff record.
83
+ #
84
+ # WHY NOT THE OBVIOUS STRONGER RULE — "a commit touching an open scope must
85
+ # name THAT id". MEASURED, not argued, over the last 100 commits of this repo:
86
+ #
87
+ # rule would refuse
88
+ # must name the holding id, directory claims bind 19 / 100
89
+ # must name the holding id, exact file claims only 10 / 100
90
+ # id-less only, directory claims bind (SHIPPED) 1 / 100
91
+ #
92
+ # 20 of those 100 commits name no id at all, so the shipped rule is quiet on 19
93
+ # of the 20 it is even eligible to look at. The one it refuses is the target
94
+ # shape, not a false positive: its fragment's `issue:` line is EMPTY and it
95
+ # writes a file another dispatch was holding, undispatched-looking and
96
+ # unlanded. Reproduce the table over your own history with
97
+ # `lib/handoff-guard.sh --range HEAD~100..HEAD`.
98
+ #
99
+ # The stronger rule's refusals are not evasions. A scope line is a prediction
100
+ # (`--files` is advisory, per bin/dispatch), and it routinely lists hub or
101
+ # generated files — src/shared/types.ts, internal/controller/controller.mjs,
102
+ # the entry point and a generated controller — that every other landing
103
+ # also touches. Refusing 10-20% of ordinary landings is how a gate becomes
104
+ # noise: "a job that cries wolf gets ignored, which is worse than no job"
105
+ # (lib/fail-first.mjs). A commit that names an id is already inside the
106
+ # protocol and the check above engages on it; a commit that names none is the
107
+ # one case where nothing engages at all.
108
+ #
109
+ # THE RESIDUAL GAP, STATED RATHER THAN PAPERED OVER. A commit that names id A
110
+ # and quietly carries a file held by agent B still passes this check. It does
111
+ # not pass unexamined — A's own handoff record is demanded above — but B's work
112
+ # can ride along under A's id. Closing that is the 10/100 rule, and the
113
+ # measurement says the cost is not payable. `bin/handoff --lift` is the
114
+ # other end of that case: it refuses to lift a file outside the agent's scope.
115
+ #
116
+ # NEVER CLAIMABLE, mirroring bin/dispatch's NEVER_CLAIMED: changelog.d/,
117
+ # because every agent writes its own fragment there BY DESIGN, and
118
+ # docs/CHANGELOG.md, which is collated output no one hand-edits (CLAUDE.md) and
119
+ # which was the single false positive in the 100-commit measurement — a
120
+ # `docs/` scope claim walling off `npm run changelog:collate`.
121
+ #
122
+ # SAME ESCAPE HATCH, both halves: SKIP_HANDOFF=1 for the hook, `[skip handoff]`
123
+ # in the message for the audit modes. There is no CI mirror to satisfy here
124
+ # (the log is gitignored), but the marker is what makes `--range` quiet later.
125
+ #
126
+ # TIME IS READ FROM THE COMMIT, NOT FROM NOW, in the audit modes: the question
127
+ # is whether a dispatch was open AT THE MOMENT THE COMMIT WAS MADE. `--range`
128
+ # over history is therefore also the measurement instrument for the table
129
+ # above; re-run it before changing the rule.
130
+
131
+ set -euo pipefail
132
+
133
+ # ENTROPY_MACHINES_HOME is the harness DIRECTORY — where config.py sits — which may be
134
+ # the repo root or a subdirectory of it. It is not a root and not a separate
135
+ # repo (see lib/roots.sh). The shim installed by lib/install-hooks.sh exports
136
+ # it; the fallback is for direct invocation, by hand or from a test.
137
+ #
138
+ # RESOLVED FIRST, before anything references it. It used to be assigned BELOW
139
+ # the cutoff block, which then had to re-derive it inline — and a single
140
+ # reference to the not-yet-assigned variable was an unbound-variable crash
141
+ # under `set -u`, i.e. a gate that died instead of refusing.
142
+ if [ -z "${ENTROPY_MACHINES_HOME:-}" ]; then
143
+ ENTROPY_MACHINES_HOME="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)"
144
+ fi
145
+
146
+ # TWO DIFFERENT TREES, one root. Both are computed here so the distinction is
147
+ # visible in one place:
148
+ #
149
+ # TREE the working tree this commit belongs to — `--show-toplevel`, i.e.
150
+ # the LINKED WORKTREE when a worker is committing from one. The staged
151
+ # changelog.d fragments being committed are on disk there and nowhere
152
+ # else, so fragment reads must use this.
153
+ # ROOT the repository's MAIN checkout — `--git-common-dir` via
154
+ # lib/roots.sh. The tracker's note log lives under .entropy-machines/, which is
155
+ # gitignored and therefore absent from every worktree, so the dispatch
156
+ # memory must be read from here. Resolving it with --show-toplevel
157
+ # found no memory file inside a worktree and the guard passed
158
+ # everything in silence.
159
+ #
160
+ # `|| true` on both: a guard that cannot resolve its own inputs must decline
161
+ # to judge (below), not die. Dying inside a hook reads as a broken tool.
162
+ . "$(dirname -- "$0")/roots.sh"
163
+ TREE="$(git rev-parse --show-toplevel 2>/dev/null || true)"
164
+ ROOT="$(entropy_machines_root 2>/dev/null || true)"
165
+
166
+ # Empty unless the project opts in — see the grandfathering note above.
167
+ CUTOFF="$(
168
+ cd "${TREE:-$PWD}" 2>/dev/null &&
169
+ python3 "$ENTROPY_MACHINES_HOME/lib/config.py" get guards.handoffCutoff 2>/dev/null | tr -d '"' || true
170
+ )"
171
+ # `if`, not `[ ... ] && ...` — under `set -e` a failing test as the last
172
+ # command of a compound exits the script, which would make this gate pass
173
+ # everything in silence. That is the exact failure this file exists to prevent.
174
+ if [ "$CUTOFF" = "null" ]; then CUTOFF=""; fi
175
+ SKIP_MARKER='[skip handoff]'
176
+
177
+ # HANDOFF_MEMORY is a TEST SEAM, not a bypass — tests/core/commit-gate-open-
178
+ # dispatch.test.ts points it at a fixture so it can exercise the refuse path
179
+ # without writing fake DISPATCH notes into the log two other sessions are
180
+ # reading. SKIP_HANDOFF is the supported escape hatch; do not set this one in
181
+ # CI. (Until 2026-08-27 this comment said "the self-test below", and there was
182
+ # no self-test below and never had been.)
183
+ # Where the note log lives is the tracker backend's business, so ask it rather
184
+ # than hardcoding a path. Falls back to the built-in backend's default.
185
+ if [ -n "${HANDOFF_MEMORY:-}" ]; then
186
+ MEMORY="$HANDOFF_MEMORY"
187
+ elif [ -n "$ROOT" ]; then
188
+ MEMORY="$ROOT/$(
189
+ cd "$ROOT" 2>/dev/null &&
190
+ python3 "$ENTROPY_MACHINES_HOME/lib/config.py" get tracker.file.path 2>/dev/null | tr -d '"' || true
191
+ )"
192
+ else
193
+ MEMORY=""
194
+ fi
195
+
196
+ # .entropy-machines/ is gitignored per-checkout state. No memory file means no way to
197
+ # know what was dispatched, so the guard has nothing to say — it must pass
198
+ # rather than block a commit it cannot reason about. Same for an unresolvable
199
+ # root: decline to judge, do not die.
200
+ if [ -z "$MEMORY" ] || [ ! -f "$MEMORY" ]; then
201
+ exit 0
202
+ fi
203
+
204
+ if [ "${SKIP_HANDOFF:-}" = "1" ]; then
205
+ echo "handoff-guard: SKIP_HANDOFF=1 — skipped."
206
+ exit 0
207
+ fi
208
+
209
+ mode=""; arg=""
210
+ case "${1:-}" in
211
+ --message|--commit|--range) mode="$1"; arg="${2:-}" ;;
212
+ *) echo "usage: handoff-guard.sh --message <file> | --commit <sha> | --range A..B" >&2; exit 2 ;;
213
+ esac
214
+ [ -n "$arg" ] || { echo "handoff-guard: $mode needs an argument" >&2; exit 2; }
215
+
216
+ # Issue ids named by the changelog.d fragments this commit adds.
217
+ #
218
+ # WHY THE MESSAGE IS NOT ENOUGH. The gate keyed only on ids in the commit
219
+ # message, so simply forgetting to write "(i-foo)" turned it off — silently,
220
+ # with no diagnostic, in the one place the whole protocol is mechanical. That is
221
+ # not a bypass anyone has to intend; it is a typo-grade omission, and a gate you
222
+ # disable by forgetting is a gate that protects only the people who did not need
223
+ # it. Every commit touching the project's code paths must already carry a fragment
224
+ # (changelog-guard.sh), and a fragment declares `issue:` — so the id is
225
+ # recoverable from the commit's own contents. Both sources are unioned.
226
+ # $2 is a rev to read the fragments FROM; empty means the working tree (the
227
+ # --message case, where the commit does not exist yet and the files are staged).
228
+ # The audit modes pass a sha, because a fragment can be collated away later and
229
+ # reading the worktree would then find nothing.
230
+ fragment_ids() {
231
+ local files="$1" rev="${2:-}" f body ids=""
232
+ for f in $files; do
233
+ case "$f" in changelog.d/*.md) ;; *) continue ;; esac
234
+ if [ -n "$rev" ]; then
235
+ body=$(git show "$rev:$f" 2>/dev/null || true)
236
+ elif [ -n "$TREE" ] && [ -f "$TREE/$f" ]; then
237
+ # $TREE, not $ROOT: the fragment being committed is staged in THIS
238
+ # working tree, which is a linked worktree when a worker is committing.
239
+ body=$(cat "$TREE/$f")
240
+ else
241
+ continue
242
+ fi
243
+ ids="$ids $(printf '%s' "$body" | sed -n 's/^issue:[[:space:]]*//p' | head -1)"
244
+ done
245
+ printf '%s' "$ids"
246
+ }
247
+
248
+ # ONE definition of "which ids does this commit name", used by both checks.
249
+ # Duplicating the pattern would let the two disagree about what counts as
250
+ # naming an id, and the second check's whole rule is "names none of them".
251
+ ids_in() {
252
+ printf '%s %s' "$1" "${2:-}" | grep -oE '\bi-[a-z0-9][a-z0-9-]{4,}' | sort -u || true
253
+ }
254
+
255
+ check_message() {
256
+ local msg="$1" label="$2" extra="${3:-}"
257
+ case "$msg" in *"$SKIP_MARKER"*) return 0 ;; esac
258
+
259
+ local ids
260
+ ids=$(ids_in "$msg" "$extra")
261
+ [ -n "$ids" ] || return 0
262
+
263
+ local rc=0
264
+ for id in $ids; do
265
+ MEMORY="$MEMORY" ID="$id" CUTOFF="$CUTOFF" LABEL="$label" python3 - <<'PY' || rc=1
266
+ import json, os, sys
267
+
268
+ mem, iid = os.environ["MEMORY"], os.environ["ID"]
269
+ cutoff, label = os.environ["CUTOFF"], os.environ["LABEL"]
270
+
271
+ def records(path):
272
+ """Every note in the store, whatever shape the store is.
273
+
274
+ TWO SHAPES, because this gate outlived one of them and DIED on the other.
275
+ lib/tracker-file writes ONE JSON DOCUMENT -- {"issues": {...}, "notes":
276
+ [...]} -- pretty-printed over many lines. The reader here used to iterate
277
+ LINES and json.loads() each one, which is the shape of an older flat JSONL
278
+ log. Against the current store that mostly raised JSONDecodeError and was
279
+ skipped, until a line that happens to be a bare JSON string on its own
280
+ (` "src/main.c"`, one element of a pretty-printed scope array)
281
+ parsed CLEANLY to a str, and `.get` on a str is an AttributeError. The gate
282
+ then exited non-zero with a traceback: every commit naming any i- id was
283
+ blocked by what read as a broken tool rather than a rule. Confirmed on the
284
+ pristine file, so it predates the one-root collapse.
285
+
286
+ Both shapes are read, and anything unparseable yields nothing rather than
287
+ raising -- a guard that cannot read its own store must decline to judge.
288
+ """
289
+ try:
290
+ with open(path, encoding="utf-8") as f:
291
+ raw = f.read()
292
+ except OSError:
293
+ return []
294
+ try:
295
+ doc = json.loads(raw)
296
+ except json.JSONDecodeError:
297
+ doc = None
298
+ if isinstance(doc, dict) and isinstance(doc.get("notes"), list):
299
+ return [r for r in doc["notes"] if isinstance(r, dict)]
300
+ out = []
301
+ for line in raw.splitlines():
302
+ line = line.strip()
303
+ if not line:
304
+ continue
305
+ try:
306
+ e = json.loads(line)
307
+ except json.JSONDecodeError:
308
+ continue
309
+ if isinstance(e, dict):
310
+ out.append(e)
311
+ return out
312
+
313
+
314
+ dispatched = handed = None
315
+ for e in records(mem):
316
+ ts = e.get("ts") or ""
317
+ # A record carries the verb structurally (tracker-file / lib/notes.py) or
318
+ # inline at the head of a free-text note (the older flat log). Match on the
319
+ # note BODY as well as the issue key: --issue is optional on `remember`,
320
+ # and a note filed without it still records the dispatch.
321
+ verb = e.get("verb") or ""
322
+ issue = e.get("issue") or ""
323
+ # The free text lives at the top level in the old flat log and under
324
+ # "fields" in lib/notes.py's record. bin/handoff files its handoff as
325
+ # verb NOTE with the verb word at the head of fields.text, so reading only
326
+ # the top level saw the DISPATCH and never the HANDOFF -- the gate would
327
+ # then refuse the same commit forever, after the handoff was recorded.
328
+ text = e.get("text")
329
+ if not isinstance(text, str):
330
+ fields = e.get("fields")
331
+ text = fields.get("text") if isinstance(fields, dict) else None
332
+ if not isinstance(text, str):
333
+ text = ""
334
+ is_dispatch = (verb == "DISPATCH" and issue == iid) or text.startswith(f"DISPATCH {iid} ")
335
+ is_handoff = (verb == "HANDOFF" and issue == iid) or text.startswith(f"HANDOFF {iid} ")
336
+ if is_dispatch:
337
+ dispatched = ts
338
+ elif is_handoff:
339
+ handed = ts
340
+
341
+ if dispatched is None:
342
+ sys.exit(0) # nobody dispatched this — not agent work
343
+ if handed is not None and handed > dispatched:
344
+ sys.exit(0) # handed off after the most recent dispatch
345
+
346
+ stale = handed is not None # handed off, then re-dispatched
347
+ why = ("the handoff on record predates the latest dispatch"
348
+ if stale else "no handoff was recorded")
349
+
350
+ if cutoff and dispatched < cutoff:
351
+ print(f"handoff-guard: WARNING on {label} — {iid} was dispatched at "
352
+ f"{dispatched} and {why}.")
353
+ print(f" Dispatched before this project's handoff cutoff ({cutoff}), so "
354
+ f"not blocked. Record it anyway once the work is verified:")
355
+ print(f' bin/handoff {iid} --changed "..." --verified "..." '
356
+ f'[--found "..."] [--next "..."] [--clean]')
357
+ sys.exit(0)
358
+
359
+ print(f"handoff-guard: REFUSED on {label} — {iid} was dispatched at "
360
+ f"{dispatched} and {why}.", file=sys.stderr)
361
+ print(" An agent's dead ends, out-of-scope findings and assumptions live only "
362
+ "in a worktree that is about to be deleted.", file=sys.stderr)
363
+ print(f' bin/handoff {iid} --changed "..." --verified "..." '
364
+ f'[--found "..."] [--next "..."] [--clean]', file=sys.stderr)
365
+ print(f" Genuinely nothing to hand off: pass --clean. Not agent work at all: "
366
+ f"SKIP_HANDOFF=1 and '{'[skip handoff]'}' in the message.", file=sys.stderr)
367
+ sys.exit(1)
368
+ PY
369
+ done
370
+ return $rc
371
+ }
372
+
373
+ # An id-less commit into a scope some dispatch is holding right now. See the
374
+ # long block at the top of this file for the rule, the measurement behind it,
375
+ # and the residual gap it deliberately leaves open.
376
+ check_scope() {
377
+ local msg="$1" label="$2" paths="$3" when="$4" extra="${5:-}"
378
+ case "$msg" in *"$SKIP_MARKER"*) return 0 ;; esac
379
+ # Named an id: the id-keyed check above owns this commit. Measured — see the
380
+ # table in the header; demanding the SPECIFIC id here refuses 10-20% of
381
+ # ordinary landings.
382
+ [ -z "$(ids_in "$msg" "$extra")" ] || return 0
383
+ [ -n "$paths" ] || return 0
384
+
385
+ MEMORY="$MEMORY" LABEL="$label" PATHS="$paths" WHEN="$when" \
386
+ CLAIM_HOURS="${DISPATCH_CLAIM_HOURS:-24}" python3 - <<'PY'
387
+ import datetime, json, os, re, sys
388
+
389
+ paths = os.environ["PATHS"].split()
390
+ def _ts(v):
391
+ """Parse a note/commit timestamp, tolerating a trailing Z.
392
+
393
+ tracker-file stamps notes UTC as `...:43Z`; this reader's format string had
394
+ no %z and no Z, so EVERY note raised ValueError and was skipped. It was
395
+ invisible because the JSONL crash above aborted the block before reaching
396
+ here -- fixing that crash turned a gate that exploded into one that
397
+ silently passed everything. Both halves are the same defect: a guard that
398
+ cannot read its own store must refuse or decline, never quietly allow.
399
+ """
400
+ if not isinstance(v, str):
401
+ return None
402
+ v = v.strip()
403
+ if v.endswith("Z"):
404
+ v = v[:-1]
405
+ try:
406
+ return datetime.datetime.strptime(v, "%Y-%m-%dT%H:%M:%S")
407
+ except ValueError:
408
+ return None
409
+
410
+
411
+ label = os.environ["LABEL"]
412
+ try:
413
+ hours = float(os.environ.get("CLAIM_HOURS") or 24)
414
+ except ValueError:
415
+ hours = 24.0
416
+ now = _ts(os.environ["WHEN"])
417
+ if now is None:
418
+ sys.exit(0) # cannot place the commit in time — say nothing
419
+
420
+ # NEVER CLAIMABLE. changelog.d/ is one file per entry BY DESIGN so two agents
421
+ # never contend; docs/CHANGELOG.md is collated output nobody hand-edits, and a
422
+ # `docs/` scope claim over it was a false positive in the 100-commit
423
+ # measurement.
424
+ #
425
+ # EXEMPTED ON BOTH SIDES, and only one of them is enough on its own. Dropping
426
+ # these from the CLAIM covers `scope: changelog.d/`; dropping them from the
427
+ # COMMIT'S PATHS covers the other direction, a `docs/` claim swallowing
428
+ # docs/CHANGELOG.md through the prefix match. The first version had only the
429
+ # claim-side filter and the audit refused the changelog collation commit.
430
+ NEVER_CLAIMED = ("changelog.d", "docs/CHANGELOG.md")
431
+
432
+ def exempt(p):
433
+ return any(p == n or p.startswith(n + "/") for n in NEVER_CLAIMED)
434
+
435
+ paths = [p for p in paths if not exempt(p)]
436
+ if not paths:
437
+ sys.exit(0)
438
+
439
+ # ANCHORED TO THE START OF THE NOTE BODY, NOT SEARCHED FOR IN IT. Of the 135
440
+ # notes in the live log whose text contains "DISPATCH" on 2026-08-27, only 106
441
+ # ARE one; the other 29 mention it inside a HANDOFF's free text. A handoff note
442
+ # that quoted this very format was read as a dispatch once and left two issues
443
+ # holding their files after they were handed off (see bin/dispatch). Here
444
+ # the subject is the JSON `text` field, so `^` is exact rather than a prefix
445
+ # guess, and both verbs come from ONE match so their order cannot matter.
446
+ VERB = re.compile(r"^(DISPATCH|HANDOFF)\s+(\S+)\s+—")
447
+
448
+ def strip(p):
449
+ p = re.sub(r"\(own\)$", "", p)
450
+ p = re.sub(r"/\*.*$", "", p)
451
+ return p.rstrip("/")
452
+
453
+ # TWO STORE SHAPES, TWO RECORD SHAPES, AND THIS READER MUST SURVIVE BOTH.
454
+ #
455
+ # SHAPE OF THE FILE. tracker-file writes ONE pretty-printed JSON document;
456
+ # older stores were JSONL. Reading the pretty-printed form a line at a time
457
+ # makes `json.loads` succeed on a bare value line — `"src/main.c"` parses to a
458
+ # str — and the next `.get()` raised AttributeError. That crash is why this
459
+ # check was not gating: it exited on a traceback instead of a verdict, and a
460
+ # gate that cannot read its own store must DECLINE, never explode.
461
+ #
462
+ # SHAPE OF A RECORD. Notes now carry structured fields
463
+ # ({"verb": "DISPATCH", "issue": ..., "fields": {"scope": [...]}}); they used
464
+ # to carry one rendered em-dash-separated "text" string. Structured is read
465
+ # first and the regex is the fallback, so a store holding both still works.
466
+ def _load_notes(path):
467
+ try:
468
+ with open(path, encoding="utf-8") as f:
469
+ raw = f.read()
470
+ except OSError:
471
+ return []
472
+ try:
473
+ doc = json.loads(raw)
474
+ except json.JSONDecodeError:
475
+ doc = None
476
+ if isinstance(doc, dict) and isinstance(doc.get("notes"), list):
477
+ return [r for r in doc["notes"] if isinstance(r, dict)]
478
+ if isinstance(doc, list):
479
+ return [r for r in doc if isinstance(r, dict)]
480
+ out = []
481
+ for line in raw.splitlines():
482
+ line = line.strip()
483
+ if not line:
484
+ continue
485
+ try:
486
+ e = json.loads(line)
487
+ except json.JSONDecodeError:
488
+ continue
489
+ if isinstance(e, dict):
490
+ out.append(e)
491
+ return out
492
+
493
+
494
+ def _read(e):
495
+ """(verb, issue, scope-string) for one note, or None if it is not one."""
496
+ verb = e.get("verb")
497
+ issue = e.get("issue")
498
+ if isinstance(verb, str) and isinstance(issue, str) and verb in ("DISPATCH", "HANDOFF"):
499
+ fields = e.get("fields")
500
+ scope = fields.get("scope") if isinstance(fields, dict) else None
501
+ if isinstance(scope, list):
502
+ scope = " ".join(str(x) for x in scope)
503
+ return verb, issue, (scope if isinstance(scope, str) else "")
504
+ text = e.get("text")
505
+ if not isinstance(text, str):
506
+ return None
507
+ m = VERB.match(text)
508
+ if not m:
509
+ return None
510
+ # Searched from the END OF THE ID GROUP: VERB already consumed the first em
511
+ # dash. Bounded by `— brief:` so the denylist field, which is also a path
512
+ # list and sits after the brief, can never be read as scope.
513
+ hit = re.search(r"—\s+scope:\s*(.*?)\s+—\s+brief:", text[m.end(2):])
514
+ return m.group(1), m.group(2), (hit.group(1).strip() if hit else "")
515
+
516
+
517
+ events = []
518
+ for e in _load_notes(os.environ["MEMORY"]):
519
+ parsed = _read(e)
520
+ if not parsed:
521
+ continue
522
+ ts = _ts(e.get("ts"))
523
+ if ts is None:
524
+ continue
525
+ if ts > now:
526
+ continue # not yet true when this commit was made
527
+ events.append((ts, parsed))
528
+ events.sort(key=lambda r: r[0])
529
+
530
+ live = {}
531
+ for ts, (verb, iid, scope) in events:
532
+ if verb == "HANDOFF":
533
+ live.pop(iid, None)
534
+ continue
535
+ if not scope:
536
+ continue
537
+ live[iid] = (ts, scope)
538
+
539
+ hits = []
540
+ for iid, (ts, scope) in live.items():
541
+ if now - ts >= datetime.timedelta(hours=hours):
542
+ continue # the claim expired, same window bin/dispatch uses
543
+ held = [strip(p) for p in re.split(r"[\s,]+", scope) if strip(p)]
544
+ held = [p for p in held if not exempt(p) and p not in ("(none)", "(unavailable)")]
545
+ for f in paths:
546
+ if any(f == p or f.startswith(p + "/") for p in held):
547
+ hits.append((iid, f, ts))
548
+ break
549
+
550
+ if not hits:
551
+ sys.exit(0)
552
+
553
+ print(f"handoff-guard: REFUSED on {label} — it names no issue id, and it "
554
+ f"touches a file an open dispatch is holding:", file=sys.stderr)
555
+ for iid, f, ts in sorted(hits):
556
+ print(f" {f} — held by {iid}, dispatched {ts.isoformat()}, not handed off",
557
+ file=sys.stderr)
558
+ print(" Landing that agent's work: name the id in the message, then record "
559
+ "the handoff:", file=sys.stderr)
560
+ print(' bin/handoff <id> --changed "..." --verified "..." '
561
+ '[--found "..."] [--next "..."] [--clean]', file=sys.stderr)
562
+ print(" Unrelated work that happens to sit in an open scope: SKIP_HANDOFF=1 "
563
+ "and '[skip handoff]' in the message.", file=sys.stderr)
564
+ sys.exit(1)
565
+ PY
566
+ }
567
+
568
+ # In --message mode the commit does not exist yet, so the fragments are whatever
569
+ # is STAGED. In the audit modes they are the files the commit itself added.
570
+ case "$mode" in
571
+ --message)
572
+ staged=$(git diff --cached --name-only --diff-filter=AM 2>/dev/null || true)
573
+ # EVERY staged path, not just added/modified: a delete or a rename's old
574
+ # name is exactly the clobber the scope check is looking for.
575
+ touched=$(git diff --cached --name-only --no-renames 2>/dev/null || true)
576
+ msg=$(cat "$arg")
577
+ frags=$(fragment_ids "$staged")
578
+ check_message "$msg" "this commit" "$frags"
579
+ check_scope "$msg" "this commit" "$touched" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$frags"
580
+ ;;
581
+ --commit)
582
+ added=$(git show --name-only --diff-filter=AM --format= "$arg" 2>/dev/null || true)
583
+ touched=$(git show --name-only --no-renames --format= "$arg" 2>/dev/null || true)
584
+ msg=$(git log -1 --format=%B "$arg")
585
+ label=$(git log -1 --format='%h %s' "$arg")
586
+ frags=$(fragment_ids "$added" "$arg")
587
+ check_message "$msg" "$label" "$frags"
588
+ # The COMMIT's own time, not now: the question is whether a dispatch was
589
+ # open at the moment it was made.
590
+ #
591
+ # TZ=UTC + format-LOCAL, and the difference is not cosmetic. There is no
592
+ # `format-utc:` in git -- an earlier pass invented it, and git answered
593
+ # `fatal: date format missing colon separator` on every audit run, so both
594
+ # non-hook modes printed an error and skipped the scope check entirely.
595
+ # `format-local` renders in whatever TZ the process has, so pinning TZ=UTC
596
+ # around it is how you actually get UTC out of git. `%cd --date=format:`
597
+ # renders in the timezone RECORDED IN THE COMMIT, which for a commit made
598
+ # elsewhere is not this machine's. Note-log timestamps are UTC with a
599
+ # trailing Z (lib/notes.py stamps `datetime.now(timezone.utc)`), so both
600
+ # sides of the comparison must be UTC.
601
+ #
602
+ # THIS COMMENT USED TO SAY LOCAL, AND SO DID THE CODE. Against UTC notes
603
+ # every dispatch looked like it was made in the FUTURE (`ts > now`), so the
604
+ # scope check skipped every note and passed every commit. It went unseen
605
+ # because a JSONL crash aborted the block before it could matter.
606
+ check_scope "$msg" "$label" "$touched" \
607
+ "$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%dT%H:%M:%SZ' "$arg")" "$frags"
608
+ ;;
609
+ --range)
610
+ rc=0
611
+ for sha in $(git rev-list --no-merges "$arg"); do
612
+ added=$(git show --name-only --diff-filter=AM --format= "$sha" 2>/dev/null || true)
613
+ touched=$(git show --name-only --no-renames --format= "$sha" 2>/dev/null || true)
614
+ msg=$(git log -1 --format=%B "$sha")
615
+ label=$(git log -1 --format='%h %s' "$sha")
616
+ frags=$(fragment_ids "$added" "$sha")
617
+ check_message "$msg" "$label" "$frags" || rc=1
618
+ check_scope "$msg" "$label" "$touched" \
619
+ "$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%dT%H:%M:%SZ' "$sha")" "$frags" || rc=1
620
+ done
621
+ exit $rc
622
+ ;;
623
+ esac