entropy-machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
package/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:])