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/lib/notes.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""notes.py — the one place that understands a tracker note record.
|
|
3
|
+
|
|
4
|
+
A note record is one JSON object, one per line (JSONL), oldest first:
|
|
5
|
+
|
|
6
|
+
{"ts":"2026-08-27T19:04:11Z","verb":"DISPATCH","issue":"i-foo",
|
|
7
|
+
"actor":"orchestrator","fields":{"scope":["src/a.ts"],"brief":"..."}}
|
|
8
|
+
|
|
9
|
+
`verb` names the record type (DISPATCH, HANDOFF, INTERROGATION, or a
|
|
10
|
+
backend-defined string — consumers ignore verbs they do not know). `fields`
|
|
11
|
+
is free-form per verb; array-shaped fields (`scope`, `denylist`, `found`,
|
|
12
|
+
`assumed`) are always arrays here, never a delimited string, so nothing
|
|
13
|
+
downstream has to guess whether a scope line was comma- or space-joined.
|
|
14
|
+
|
|
15
|
+
WHY THIS FILE EXISTS. The tool this one replaced re-parsed a free-text,
|
|
16
|
+
em-dash-delimited note format — "DISPATCH <id> — scope: ... — brief: ..." —
|
|
17
|
+
independently in four separate places: a denylist scanner, a sed extraction,
|
|
18
|
+
an interrogation-state scanner, and a gate script that read the tracker's
|
|
19
|
+
raw storage file directly and re-implemented the same regex a fourth time.
|
|
20
|
+
Each copy anchored its verb-matching slightly differently, and the one time
|
|
21
|
+
they disagreed, a HANDOFF note that merely quoted the DISPATCH format in its
|
|
22
|
+
own free text was read BACK as a live dispatch — the log describing the
|
|
23
|
+
format got misread as an entry in it, and two files stayed walled off for
|
|
24
|
+
forty minutes after the agent holding them had already been handed off.
|
|
25
|
+
That failure class cannot happen from this file's shape: `verb` and `issue`
|
|
26
|
+
are structured fields, not text a regex has to find, so a record that merely
|
|
27
|
+
DISCUSSES a verb in its `fields` cannot be mistaken for a record that IS
|
|
28
|
+
that verb.
|
|
29
|
+
|
|
30
|
+
Every caller that used to re-implement note parsing — the dispatcher, the
|
|
31
|
+
handoff recorder, the commit gate — imports this module (or shells out to
|
|
32
|
+
its CLI below) instead. One format, one parser, one place a format change
|
|
33
|
+
has to land.
|
|
34
|
+
|
|
35
|
+
CLI, all subcommands read note records from stdin (see load_records for the
|
|
36
|
+
shapes accepted) unless noted otherwise; anything unparseable is skipped, not
|
|
37
|
+
fatal — an append-only log a human has hand-edited once should degrade, not
|
|
38
|
+
die:
|
|
39
|
+
|
|
40
|
+
notes.py encode --verb V [--actor A] [--field k=v]...
|
|
41
|
+
Print one line of JSON: the payload a caller passes as the <text>
|
|
42
|
+
argument to a `remember` call. A field named twice accumulates into an
|
|
43
|
+
array; scope/denylist accumulate into an array even given once, split
|
|
44
|
+
on comma OR whitespace, because the format this replaced parsed that
|
|
45
|
+
inconsistently across its four call sites and every caller quietly
|
|
46
|
+
assumed its own convention was the only one in use.
|
|
47
|
+
|
|
48
|
+
notes.py claims [--self ID] [--hours N] [--now ISO] [--exclude PATH]...
|
|
49
|
+
Live file-scope claims: for every issue whose most recent DISPATCH has
|
|
50
|
+
not been released by a later HANDOFF and has not aged out past --hours
|
|
51
|
+
(default 24 — a claim is a round's worth of time, not a permanent
|
|
52
|
+
wall), print:
|
|
53
|
+
CLAIM\t<issue>\t<space-joined paths>
|
|
54
|
+
one line per claiming issue, then always:
|
|
55
|
+
PATHS\t<space-joined unique paths>
|
|
56
|
+
--self excludes an issue reclaiming its own files. --exclude removes
|
|
57
|
+
paths that can never be contended (a directory every writer gets its
|
|
58
|
+
own file inside, for instance).
|
|
59
|
+
|
|
60
|
+
notes.py hits --hours N [--now ISO] [--exclude PATH]... -- PATH...
|
|
61
|
+
Given paths a commit or a worker actually touched, which of them
|
|
62
|
+
collide with a LIVE claim (see `claims`) held by someone else. Prints:
|
|
63
|
+
HIT\t<path>\t<issue>\t<ts>
|
|
64
|
+
one line per collision.
|
|
65
|
+
|
|
66
|
+
notes.py record --issue ID --verb V [--verb V]... [--last]
|
|
67
|
+
Print the matching record(s) for one issue, restricted to the given
|
|
68
|
+
verb(s), as JSON — one per line, oldest first. --last prints only the
|
|
69
|
+
final match (a plain 404-by-absence: nothing is printed, exit 0, if
|
|
70
|
+
there is no match at all).
|
|
71
|
+
|
|
72
|
+
notes.py interrogation-state --issue ID
|
|
73
|
+
One of: no-dispatch | not-required | recorded | missing. See the
|
|
74
|
+
docstring on interrogation_state() below for what each means.
|
|
75
|
+
|
|
76
|
+
notes.py dispatch-fields --issue ID
|
|
77
|
+
The fields of the LAST DISPATCH record for one issue, one per line,
|
|
78
|
+
for a shell caller that must not hand-roll note parsing (bin/handoff):
|
|
79
|
+
DISPATCH\t1|0 was a DISPATCH record found at all
|
|
80
|
+
SCOPE\t<space-joined> the declared --files scope
|
|
81
|
+
DENYLIST\t<space-joined>|(none)|(unavailable)
|
|
82
|
+
INTERROGATION\t<value-or-empty>
|
|
83
|
+
The three denylist states are NOT interchangeable and are preserved
|
|
84
|
+
exactly: a real list is enforced, `(none)` means the claim log was read
|
|
85
|
+
and nobody held anything, `(unavailable)` means it could not be read and
|
|
86
|
+
the caller must fall back to enforcing the scope as an allowlist.
|
|
87
|
+
Exit 0 always when the stream could be read; a caller that gets a
|
|
88
|
+
non-zero exit has an unreadable store and must refuse, not proceed.
|
|
89
|
+
|
|
90
|
+
notes.py dispatch-handoff-ts --issue ID
|
|
91
|
+
Print the timestamp of the last DISPATCH and last HANDOFF record for
|
|
92
|
+
one issue (each on its own line, empty if none):
|
|
93
|
+
DISPATCHED\t<ts-or-empty>
|
|
94
|
+
HANDED\t<ts-or-empty>
|
|
95
|
+
"""
|
|
96
|
+
from __future__ import annotations
|
|
97
|
+
|
|
98
|
+
import json
|
|
99
|
+
import re
|
|
100
|
+
import sys
|
|
101
|
+
from datetime import datetime, timedelta, timezone
|
|
102
|
+
|
|
103
|
+
ARRAY_FIELDS = {"scope", "denylist", "found", "assumed"}
|
|
104
|
+
|
|
105
|
+
# Of the array-shaped fields, only these two are built by a SHELL caller as one
|
|
106
|
+
# delimited path list, so only these two are split on receipt. `found` and
|
|
107
|
+
# `assumed` are prose an agent wrote; splitting those on whitespace turned one
|
|
108
|
+
# sentence into a list of words, which is why the encode CLI now wraps them
|
|
109
|
+
# instead. Both still land as arrays — the on-disk shape in ARRAY_FIELDS is
|
|
110
|
+
# unchanged, only the way a single --field value is coerced into one.
|
|
111
|
+
SPLIT_FIELDS = {"scope", "denylist"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
# timestamps
|
|
116
|
+
# --------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def now_iso() -> str:
|
|
119
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_ts(value):
|
|
123
|
+
"""Parse a note's `ts`. Returns an aware UTC datetime, or None if the
|
|
124
|
+
value is empty or does not match either accepted shape. A record with an
|
|
125
|
+
unparseable timestamp is never dropped by a caller on that basis alone —
|
|
126
|
+
every caller here that uses ts for expiry treats "unknown" as "still
|
|
127
|
+
live", because failing safe costs a false wall and failing open costs a
|
|
128
|
+
clobber, and a clobber is the more expensive mistake."""
|
|
129
|
+
if not value:
|
|
130
|
+
return None
|
|
131
|
+
v = str(value).strip()
|
|
132
|
+
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"):
|
|
133
|
+
try:
|
|
134
|
+
return datetime.strptime(v, fmt).replace(tzinfo=timezone.utc)
|
|
135
|
+
except ValueError:
|
|
136
|
+
continue
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
# scope / path helpers
|
|
142
|
+
# --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def normalize_scope(value) -> list[str]:
|
|
145
|
+
"""Coerce a scope-shaped value to a list of non-empty strings. Accepts a
|
|
146
|
+
real list (the on-disk shape) or a bare string, comma- or
|
|
147
|
+
whitespace-delimited (the shape a shell caller builds most naturally,
|
|
148
|
+
and the one place the two delimiters used to disagree)."""
|
|
149
|
+
if value is None:
|
|
150
|
+
return []
|
|
151
|
+
if isinstance(value, list):
|
|
152
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
|
153
|
+
return [p for p in re.split(r"[\s,]+", str(value).strip()) if p]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def strip_annotation(path: str) -> str:
|
|
157
|
+
"""A scope entry can carry a trailing annotation: "path/(own)" (this
|
|
158
|
+
agent's own claim, exempted at the call site that cares), "dir/*.md" (a
|
|
159
|
+
glob within a directory), or a bare "dir/". Strip all three down to the
|
|
160
|
+
bare path a prefix match can use."""
|
|
161
|
+
path = re.sub(r"\(own\)$", "", path)
|
|
162
|
+
path = re.sub(r"/\*.*$", "", path)
|
|
163
|
+
return path.rstrip("/")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def path_under(path: str, claimed: list[str]) -> bool:
|
|
167
|
+
"""True if `path` is one of `claimed`, or nested under one of them."""
|
|
168
|
+
for p in claimed:
|
|
169
|
+
p = strip_annotation(p)
|
|
170
|
+
if not p:
|
|
171
|
+
continue
|
|
172
|
+
if path == p or path.startswith(p + "/"):
|
|
173
|
+
return True
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def paths_overlap(a: str, b: str) -> bool:
|
|
178
|
+
"""True if `a` and `b` name the same path or one nests inside the
|
|
179
|
+
other, in either direction — used for an early warning where the
|
|
180
|
+
direction of containment is not yet known (an advisory --files entry
|
|
181
|
+
against a live claim, say)."""
|
|
182
|
+
a, b = strip_annotation(a), strip_annotation(b)
|
|
183
|
+
if not a or not b:
|
|
184
|
+
return False
|
|
185
|
+
return a == b or a.startswith(b + "/") or b.startswith(a + "/")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
# record parsing
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def parse_line(line: str):
|
|
193
|
+
line = line.strip()
|
|
194
|
+
if not line:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
rec = json.loads(line)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
return None
|
|
200
|
+
if not isinstance(rec, dict):
|
|
201
|
+
return None
|
|
202
|
+
return rec
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_stream(text: str) -> list[dict]:
|
|
206
|
+
out = []
|
|
207
|
+
for line in text.splitlines():
|
|
208
|
+
rec = parse_line(line)
|
|
209
|
+
if rec is not None:
|
|
210
|
+
out.append(rec)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_records(text: str) -> list[dict]:
|
|
215
|
+
"""Every note in `text`, whatever shape it arrives in.
|
|
216
|
+
|
|
217
|
+
THREE SHAPES, because a reader that knows only one of them does not fail
|
|
218
|
+
loudly — it fails as a gate that passes everything.
|
|
219
|
+
|
|
220
|
+
- ONE PRETTY-PRINTED JSON DOCUMENT, `{"issues": {...}, "notes": [...]}`.
|
|
221
|
+
This is what lib/tracker-file writes to disk. Read a line at a time it
|
|
222
|
+
mostly raises JSONDecodeError and is skipped, until a line that is a
|
|
223
|
+
bare JSON string on its own (` "src/main.c"`, one element of a
|
|
224
|
+
pretty-printed scope array) parses CLEANLY to a str and the next
|
|
225
|
+
`.get()` is an AttributeError. lib/handoff-guard.sh died exactly that
|
|
226
|
+
way, and the "fix" that only stopped the crash turned a gate that
|
|
227
|
+
exploded into one that silently allowed everything.
|
|
228
|
+
- A BARE JSON ARRAY of records.
|
|
229
|
+
- JSONL, one record per line — what `bin/tracker notes` prints, and the
|
|
230
|
+
shape older logs were stored in.
|
|
231
|
+
|
|
232
|
+
Anything unparseable yields nothing rather than raising. A caller that
|
|
233
|
+
cannot read its own state must decline or refuse; it must never mistake
|
|
234
|
+
"I read nothing" for "there is nothing".
|
|
235
|
+
"""
|
|
236
|
+
raw = text or ""
|
|
237
|
+
try:
|
|
238
|
+
doc = json.loads(raw)
|
|
239
|
+
except (json.JSONDecodeError, TypeError):
|
|
240
|
+
doc = None
|
|
241
|
+
if isinstance(doc, dict) and isinstance(doc.get("notes"), list):
|
|
242
|
+
return [r for r in doc["notes"] if isinstance(r, dict)]
|
|
243
|
+
if isinstance(doc, list):
|
|
244
|
+
return [r for r in doc if isinstance(r, dict)]
|
|
245
|
+
return parse_stream(raw)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ANCHORED TO THE START OF THE NOTE BODY, NOT SEARCHED FOR IN IT — a HANDOFF
|
|
249
|
+
# note that quotes this very format was read back as a live DISPATCH once and
|
|
250
|
+
# left two issues holding their files after they had been handed off.
|
|
251
|
+
LEGACY_VERB = re.compile(r"^(DISPATCH|HANDOFF|INTERROGATION)\s+(\S+)\s+—")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def record_text(record: dict) -> str:
|
|
255
|
+
"""The free-text body of a note, wherever it lives: at the top level in the
|
|
256
|
+
old flat log, under `fields` once a structured record wrapped it (which is
|
|
257
|
+
what lib/tracker-file's decode_payload does with any note that is not this
|
|
258
|
+
module's own JSON payload)."""
|
|
259
|
+
text = record.get("text")
|
|
260
|
+
if not isinstance(text, str):
|
|
261
|
+
fields = record.get("fields")
|
|
262
|
+
text = fields.get("text") if isinstance(fields, dict) else None
|
|
263
|
+
return text if isinstance(text, str) else ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def legacy_fields(text: str) -> dict:
|
|
267
|
+
"""The three fields a rendered em-dash DISPATCH line carried, parsed the
|
|
268
|
+
way its readers parsed it before this module existed. Deliberately three
|
|
269
|
+
targeted patterns rather than a general `key: value` grammar: the brief is
|
|
270
|
+
free text and can itself contain an em dash, so a general split would let a
|
|
271
|
+
brief invent a scope. Scope is bounded by `— brief:` for the same reason —
|
|
272
|
+
the denylist is also a path list and sits after the brief."""
|
|
273
|
+
out: dict = {}
|
|
274
|
+
m = re.search(r"—\s+scope:\s*(.*?)\s+—\s+brief:", text)
|
|
275
|
+
if m:
|
|
276
|
+
out["scope"] = normalize_scope(m.group(1))
|
|
277
|
+
m = re.search(r"—\s+denylist:\s*(.*)$", text)
|
|
278
|
+
if m:
|
|
279
|
+
raw = m.group(1).strip()
|
|
280
|
+
# `(none)` and `(unavailable)` are STATES, not paths — see
|
|
281
|
+
# dispatch_fields for why the difference is load-bearing.
|
|
282
|
+
out["denylist"] = raw if raw in ("(none)", "(unavailable)") else normalize_scope(raw)
|
|
283
|
+
if " — interrogation: required" in text:
|
|
284
|
+
out["interrogation"] = "required"
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def read_record(record):
|
|
289
|
+
"""(verb, issue, fields) for one note — or None if it is neither shape.
|
|
290
|
+
|
|
291
|
+
STRUCTURED FIRST, RENDERED TEXT AS THE FALLBACK, so a store holding both
|
|
292
|
+
still works. A record whose verb is structurally present wins outright; one
|
|
293
|
+
carrying the old `DISPATCH <id> — scope: … — brief: …` string is parsed out
|
|
294
|
+
of its text. Nothing here raises: a record it cannot read is not a record.
|
|
295
|
+
"""
|
|
296
|
+
if not isinstance(record, dict):
|
|
297
|
+
return None
|
|
298
|
+
verb, issue = record.get("verb"), record.get("issue")
|
|
299
|
+
fields = record.get("fields")
|
|
300
|
+
if not isinstance(fields, dict):
|
|
301
|
+
fields = {}
|
|
302
|
+
if isinstance(verb, str) and verb and verb != "NOTE" and isinstance(issue, str) and issue:
|
|
303
|
+
return verb, issue, fields
|
|
304
|
+
m = LEGACY_VERB.match(record_text(record).strip())
|
|
305
|
+
if not m:
|
|
306
|
+
return None
|
|
307
|
+
return m.group(1), m.group(2), legacy_fields(record_text(record))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def dispatch_fields(records: list[dict], issue: str):
|
|
311
|
+
"""The fields of the LAST DISPATCH record for `issue`, or None if there is
|
|
312
|
+
no DISPATCH for it at all. The two are not the same answer and the caller
|
|
313
|
+
must not collapse them: no dispatch is hand-done work and fails open; a
|
|
314
|
+
dispatch whose fields cannot be read is a broken gate and must not."""
|
|
315
|
+
found = None
|
|
316
|
+
for record in records:
|
|
317
|
+
parsed = read_record(record)
|
|
318
|
+
if parsed is None:
|
|
319
|
+
continue
|
|
320
|
+
verb, iid, fields = parsed
|
|
321
|
+
if verb == "DISPATCH" and iid == issue:
|
|
322
|
+
found = fields
|
|
323
|
+
return found
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def format_record(record: dict) -> str:
|
|
327
|
+
return json.dumps(record, separators=(",", ":"), sort_keys=True)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_record(*, ts: str, verb: str, issue: str, fields: dict, actor=None) -> dict:
|
|
331
|
+
return {"ts": ts, "verb": verb, "issue": issue, "actor": actor or "unknown", "fields": fields}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------
|
|
335
|
+
# the remember-payload convention
|
|
336
|
+
# --------------------------------------------------------------------------
|
|
337
|
+
# The adapter contract (docs/TRACKER-ADAPTER.md) keeps `remember` to exactly
|
|
338
|
+
# `remember --issue <id> <text>` so ANY backend — including a real external
|
|
339
|
+
# tracker that only understands a free-text insight — can implement it. A
|
|
340
|
+
# structured caller (this harness's own dispatcher and handoff recorder)
|
|
341
|
+
# still wants a verb and typed fields, so it encodes them as one line of JSON
|
|
342
|
+
# and passes THAT as <text>. A backend that understands the convention
|
|
343
|
+
# (lib/tracker-file does) decodes it back into a structured record; a
|
|
344
|
+
# backend that does not just stores the JSON as an opaque message, which is
|
|
345
|
+
# still a valid, readable note — degraded, not broken.
|
|
346
|
+
|
|
347
|
+
def encode_payload(verb: str, fields: dict, actor=None) -> str:
|
|
348
|
+
payload = {"verb": verb, "fields": fields}
|
|
349
|
+
if actor:
|
|
350
|
+
payload["actor"] = actor
|
|
351
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def decode_payload(text: str) -> dict:
|
|
355
|
+
try:
|
|
356
|
+
obj = json.loads(text)
|
|
357
|
+
except (json.JSONDecodeError, TypeError):
|
|
358
|
+
obj = None
|
|
359
|
+
if isinstance(obj, dict) and "verb" in obj and isinstance(obj.get("fields"), dict):
|
|
360
|
+
return {"verb": str(obj["verb"]), "actor": obj.get("actor"), "fields": obj["fields"]}
|
|
361
|
+
# Not our convention — an external tracker's own note, or a caller that
|
|
362
|
+
# just wants to log a sentence. Wrap it rather than reject it.
|
|
363
|
+
return {"verb": "NOTE", "actor": None, "fields": {"text": text}}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# --------------------------------------------------------------------------
|
|
367
|
+
# live claims (the file-scope denylist)
|
|
368
|
+
# --------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def live_claims(records: list[dict], *, exclude_issue=None, never_claimed=(),
|
|
371
|
+
hours: float = 24.0, now=None) -> "dict[str, list[str]]":
|
|
372
|
+
"""Every issue currently holding a live file-scope claim, in the order
|
|
373
|
+
its DISPATCH first appeared, most recent state per issue.
|
|
374
|
+
|
|
375
|
+
THREE THINGS MAKE THIS "LIVE", not "was ever dispatched":
|
|
376
|
+
- a later HANDOFF for the same issue releases it (verb-anchored on
|
|
377
|
+
the `issue` field — no possibility of a note that merely mentions
|
|
378
|
+
an id being mistaken for one that closes it, unlike the format this
|
|
379
|
+
replaced);
|
|
380
|
+
- a claim older than `hours` has expired. The log is append-only with
|
|
381
|
+
no delete, so a DISPATCH that was never handed off would otherwise
|
|
382
|
+
hold its files forever, and the end state of an ever-growing
|
|
383
|
+
denylist is the allowlist it was built to avoid, with worse
|
|
384
|
+
ergonomics;
|
|
385
|
+
- a path in `never_claimed` is never a wall, because some directories
|
|
386
|
+
are structurally uncontended (every writer gets its own file in
|
|
387
|
+
them) and denying one anyway reproduces the exact failure this
|
|
388
|
+
mechanism exists to prevent, through the other door.
|
|
389
|
+
"""
|
|
390
|
+
if now is None:
|
|
391
|
+
now = datetime.now(timezone.utc)
|
|
392
|
+
cutoff = now - timedelta(hours=hours)
|
|
393
|
+
|
|
394
|
+
live: dict[str, tuple] = {}
|
|
395
|
+
order: list[str] = []
|
|
396
|
+
for rec in records:
|
|
397
|
+
# read_record, not rec.get("verb"): a store still holding rendered
|
|
398
|
+
# em-dash notes must claim and release from those too, or the two
|
|
399
|
+
# readers of this log (here and lib/handoff-guard.sh, which already
|
|
400
|
+
# falls back) disagree about who is holding what.
|
|
401
|
+
parsed = read_record(rec)
|
|
402
|
+
if parsed is None:
|
|
403
|
+
continue
|
|
404
|
+
verb, issue, rec_fields = parsed
|
|
405
|
+
if not issue or verb not in ("DISPATCH", "HANDOFF"):
|
|
406
|
+
continue
|
|
407
|
+
if verb == "HANDOFF":
|
|
408
|
+
live.pop(issue, None)
|
|
409
|
+
continue
|
|
410
|
+
ts = parse_ts(rec.get("ts"))
|
|
411
|
+
if ts is not None and ts < cutoff:
|
|
412
|
+
live.pop(issue, None)
|
|
413
|
+
continue
|
|
414
|
+
scope = normalize_scope(rec_fields.get("scope"))
|
|
415
|
+
live[issue] = (ts, scope)
|
|
416
|
+
if issue not in order:
|
|
417
|
+
order.append(issue)
|
|
418
|
+
|
|
419
|
+
result: dict[str, list[str]] = {}
|
|
420
|
+
for issue in order:
|
|
421
|
+
if issue not in live or issue == exclude_issue:
|
|
422
|
+
continue
|
|
423
|
+
_, paths = live[issue]
|
|
424
|
+
kept = [p for p in paths if strip_annotation(p) not in never_claimed]
|
|
425
|
+
if kept:
|
|
426
|
+
result[issue] = kept
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --------------------------------------------------------------------------
|
|
431
|
+
# verb-anchored record lookup
|
|
432
|
+
# --------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def last_record(records: list[dict], issue: str, verbs=None):
|
|
435
|
+
"""The last record for `issue`, oldest-first input assumed, optionally
|
|
436
|
+
restricted to a set of verbs. None if there is no match."""
|
|
437
|
+
match = None
|
|
438
|
+
for rec in records:
|
|
439
|
+
if rec.get("issue") != issue:
|
|
440
|
+
continue
|
|
441
|
+
if verbs and rec.get("verb") not in verbs:
|
|
442
|
+
continue
|
|
443
|
+
match = rec
|
|
444
|
+
return match
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def interrogation_state(records: list[dict], issue: str) -> str:
|
|
448
|
+
"""
|
|
449
|
+
no-dispatch — no DISPATCH record for this issue at all (hand-done
|
|
450
|
+
work, or an unreadable tracker — both fail open here;
|
|
451
|
+
there was never an agent to question).
|
|
452
|
+
not-required — the DISPATCH record predates the interrogation
|
|
453
|
+
contract (its fields carry no `interrogation:
|
|
454
|
+
required` marker), so the agent was never told it
|
|
455
|
+
would be questioned.
|
|
456
|
+
recorded — an INTERROGATION record follows the LAST DISPATCH.
|
|
457
|
+
Ordering matters: a re-dispatch reopens the question.
|
|
458
|
+
missing — the agent was told, and nobody has asked yet.
|
|
459
|
+
"""
|
|
460
|
+
last_dispatch_i = last_interrogation_i = None
|
|
461
|
+
required = False
|
|
462
|
+
for i, rec in enumerate(records):
|
|
463
|
+
parsed = read_record(rec)
|
|
464
|
+
if parsed is None:
|
|
465
|
+
continue
|
|
466
|
+
verb, iid, fields = parsed
|
|
467
|
+
if iid != issue:
|
|
468
|
+
continue
|
|
469
|
+
if verb == "DISPATCH":
|
|
470
|
+
last_dispatch_i = i
|
|
471
|
+
required = fields.get("interrogation") == "required"
|
|
472
|
+
elif verb == "INTERROGATION":
|
|
473
|
+
last_interrogation_i = i
|
|
474
|
+
if last_dispatch_i is None:
|
|
475
|
+
return "no-dispatch"
|
|
476
|
+
if not required:
|
|
477
|
+
return "not-required"
|
|
478
|
+
if last_interrogation_i is not None and last_interrogation_i > last_dispatch_i:
|
|
479
|
+
return "recorded"
|
|
480
|
+
return "missing"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def dispatch_handoff_ts(records: list[dict], issue: str):
|
|
484
|
+
"""(dispatched_ts, handed_ts) — the timestamp of the last DISPATCH and
|
|
485
|
+
last HANDOFF record for `issue`, or None for either that never occurred."""
|
|
486
|
+
dispatched = handed = None
|
|
487
|
+
for rec in records:
|
|
488
|
+
if rec.get("issue") != issue:
|
|
489
|
+
continue
|
|
490
|
+
if rec.get("verb") == "DISPATCH":
|
|
491
|
+
dispatched = rec.get("ts")
|
|
492
|
+
elif rec.get("verb") == "HANDOFF":
|
|
493
|
+
handed = rec.get("ts")
|
|
494
|
+
return dispatched, handed
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------------------------
|
|
498
|
+
# CLI
|
|
499
|
+
# --------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def _usage():
|
|
502
|
+
print(__doc__, file=sys.stderr)
|
|
503
|
+
sys.exit(2)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _read_stdin_records() -> list[dict]:
|
|
507
|
+
return load_records(sys.stdin.read())
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _take(args, flag, multi=False):
|
|
511
|
+
"""Pull every occurrence of `--flag value` out of args, in place.
|
|
512
|
+
Returns a list if multi else the last value (or None)."""
|
|
513
|
+
out = []
|
|
514
|
+
i = 0
|
|
515
|
+
while i < len(args):
|
|
516
|
+
if args[i] == flag and i + 1 < len(args):
|
|
517
|
+
out.append(args[i + 1])
|
|
518
|
+
del args[i:i + 2]
|
|
519
|
+
else:
|
|
520
|
+
i += 1
|
|
521
|
+
if multi:
|
|
522
|
+
return out
|
|
523
|
+
return out[-1] if out else None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _cli_encode(args):
|
|
527
|
+
verb = _take(args, "--verb")
|
|
528
|
+
actor = _take(args, "--actor")
|
|
529
|
+
raw_fields = _take(args, "--field", multi=True)
|
|
530
|
+
if not verb:
|
|
531
|
+
_usage()
|
|
532
|
+
fields: dict = {}
|
|
533
|
+
for kv in raw_fields:
|
|
534
|
+
if "=" not in kv:
|
|
535
|
+
_usage()
|
|
536
|
+
k, v = kv.split("=", 1)
|
|
537
|
+
if k in fields:
|
|
538
|
+
existing = fields[k]
|
|
539
|
+
fields[k] = (existing if isinstance(existing, list) else [existing]) + [v]
|
|
540
|
+
elif k in SPLIT_FIELDS:
|
|
541
|
+
fields[k] = normalize_scope(v)
|
|
542
|
+
elif k in ARRAY_FIELDS:
|
|
543
|
+
fields[k] = [v] if v.strip() else []
|
|
544
|
+
else:
|
|
545
|
+
fields[k] = v
|
|
546
|
+
print(encode_payload(verb, fields, actor=actor))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _cli_claims(args):
|
|
550
|
+
self_id = _take(args, "--self")
|
|
551
|
+
hours = _take(args, "--hours")
|
|
552
|
+
now_s = _take(args, "--now")
|
|
553
|
+
exclude = _take(args, "--exclude", multi=True)
|
|
554
|
+
now = parse_ts(now_s) if now_s else None
|
|
555
|
+
records = _read_stdin_records()
|
|
556
|
+
claims = live_claims(
|
|
557
|
+
records, exclude_issue=self_id or None,
|
|
558
|
+
never_claimed=set(strip_annotation(p) for p in exclude),
|
|
559
|
+
hours=float(hours) if hours else 24.0, now=now,
|
|
560
|
+
)
|
|
561
|
+
paths: list[str] = []
|
|
562
|
+
for issue, held in claims.items():
|
|
563
|
+
print(f"CLAIM\t{issue}\t{' '.join(held)}")
|
|
564
|
+
for p in held:
|
|
565
|
+
if p not in paths:
|
|
566
|
+
paths.append(p)
|
|
567
|
+
print(f"PATHS\t{' '.join(paths)}")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _cli_hits(args):
|
|
571
|
+
hours = _take(args, "--hours")
|
|
572
|
+
now_s = _take(args, "--now")
|
|
573
|
+
exclude = _take(args, "--exclude", multi=True)
|
|
574
|
+
if "--" in args:
|
|
575
|
+
args = args[args.index("--") + 1:]
|
|
576
|
+
now = parse_ts(now_s) if now_s else None
|
|
577
|
+
records = _read_stdin_records()
|
|
578
|
+
claims = live_claims(
|
|
579
|
+
records, never_claimed=set(strip_annotation(p) for p in exclude),
|
|
580
|
+
hours=float(hours) if hours else 24.0, now=now,
|
|
581
|
+
)
|
|
582
|
+
# Rebuild per-issue timestamps for the report line.
|
|
583
|
+
ts_by_issue = {}
|
|
584
|
+
for rec in records:
|
|
585
|
+
if rec.get("verb") == "DISPATCH" and rec.get("issue") in claims:
|
|
586
|
+
ts_by_issue[rec["issue"]] = rec.get("ts", "")
|
|
587
|
+
for path in args:
|
|
588
|
+
for issue, held in claims.items():
|
|
589
|
+
if path_under(path, held):
|
|
590
|
+
print(f"HIT\t{path}\t{issue}\t{ts_by_issue.get(issue, '')}")
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _cli_record(args):
|
|
595
|
+
issue = _take(args, "--issue")
|
|
596
|
+
verbs = _take(args, "--verb", multi=True)
|
|
597
|
+
last = "--last" in args
|
|
598
|
+
if last:
|
|
599
|
+
args.remove("--last")
|
|
600
|
+
if not issue:
|
|
601
|
+
_usage()
|
|
602
|
+
records = _read_stdin_records()
|
|
603
|
+
matches = [r for r in records if r.get("issue") == issue and (not verbs or r.get("verb") in verbs)]
|
|
604
|
+
if last:
|
|
605
|
+
matches = matches[-1:]
|
|
606
|
+
for rec in matches:
|
|
607
|
+
print(format_record(rec))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _cli_dispatch_fields(args):
|
|
611
|
+
issue = _take(args, "--issue")
|
|
612
|
+
if not issue:
|
|
613
|
+
_usage()
|
|
614
|
+
fields = dispatch_fields(_read_stdin_records(), issue)
|
|
615
|
+
if fields is None:
|
|
616
|
+
print("DISPATCH\t0")
|
|
617
|
+
print("SCOPE\t")
|
|
618
|
+
print("DENYLIST\t")
|
|
619
|
+
print("INTERROGATION\t")
|
|
620
|
+
return
|
|
621
|
+
print("DISPATCH\t1")
|
|
622
|
+
print("SCOPE\t" + " ".join(normalize_scope(fields.get("scope"))))
|
|
623
|
+
# THREE STATES AND THEY ARE NOT INTERCHANGEABLE. A real list is enforced at
|
|
624
|
+
# lift. `(none)` means the claim log was read and nobody held anything, so
|
|
625
|
+
# every file is this agent's. ABSENT means it could not be read at all, and
|
|
626
|
+
# the caller has to fall back to enforcing the advisory scope as an
|
|
627
|
+
# allowlist. Collapsing the last two turns an unknown claim set into an
|
|
628
|
+
# empty one, which is the silent-allow this whole reader exists to prevent.
|
|
629
|
+
deny = fields.get("denylist")
|
|
630
|
+
if deny is None:
|
|
631
|
+
print("DENYLIST\t(unavailable)")
|
|
632
|
+
elif isinstance(deny, str):
|
|
633
|
+
print("DENYLIST\t" + deny.strip())
|
|
634
|
+
else:
|
|
635
|
+
paths = normalize_scope(deny)
|
|
636
|
+
print("DENYLIST\t" + (" ".join(paths) if paths else "(none)"))
|
|
637
|
+
interrogation = fields.get("interrogation")
|
|
638
|
+
print("INTERROGATION\t" + (str(interrogation) if isinstance(interrogation, str) else ""))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _cli_interrogation_state(args):
|
|
642
|
+
issue = _take(args, "--issue")
|
|
643
|
+
if not issue:
|
|
644
|
+
_usage()
|
|
645
|
+
print(interrogation_state(_read_stdin_records(), issue))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _cli_dispatch_handoff_ts(args):
|
|
649
|
+
issue = _take(args, "--issue")
|
|
650
|
+
if not issue:
|
|
651
|
+
_usage()
|
|
652
|
+
dispatched, handed = dispatch_handoff_ts(_read_stdin_records(), issue)
|
|
653
|
+
print(f"DISPATCHED\t{dispatched or ''}")
|
|
654
|
+
print(f"HANDED\t{handed or ''}")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
_CLI = {
|
|
658
|
+
"encode": _cli_encode,
|
|
659
|
+
"claims": _cli_claims,
|
|
660
|
+
"hits": _cli_hits,
|
|
661
|
+
"record": _cli_record,
|
|
662
|
+
"dispatch-fields": _cli_dispatch_fields,
|
|
663
|
+
"interrogation-state": _cli_interrogation_state,
|
|
664
|
+
"dispatch-handoff-ts": _cli_dispatch_handoff_ts,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def main(argv):
|
|
669
|
+
if not argv or argv[0] not in _CLI:
|
|
670
|
+
_usage()
|
|
671
|
+
_CLI[argv[0]](list(argv[1:]))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
if __name__ == "__main__":
|
|
675
|
+
main(sys.argv[1:])
|