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
|
@@ -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
|