graphite-code 0.3.0__py3-none-any.whl

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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/channel.py ADDED
@@ -0,0 +1,1325 @@
1
+ """Broker for the shared agent channel.
2
+
3
+ Some agents are sandboxed to their own workspace and cannot write to the channel
4
+ at all, and that restriction is deliberate. So access is *mediated* here rather
5
+ than *granted* as a path: agents call graphite, graphite writes.
6
+
7
+ Two invariants carry the whole design.
8
+
9
+ **Identity is derived, never declared.** Every agent commits under the operator's
10
+ git identity, so the `Co-Authored-By` trailer is the only answer to "who wrote
11
+ this". A caller-supplied author would let any agent forge any trailer, so the
12
+ author comes from the repository the broker is running in, resolved through a
13
+ registry committed in the channel. An unregistered repository is refused --
14
+ never defaulted to "unknown", because a permissive fallback is exactly the hole
15
+ the derivation exists to close.
16
+
17
+ **Nothing on disk is ever rewritten.** Rounds are created once. Status is an
18
+ append-only event log (`status/NN/SEQ-*.json`) folded to a current value at read
19
+ time. A status field inside a round would mean editing the round, and a log you
20
+ can rewrite answers "who said what" only as well as its last edit.
21
+
22
+ See docs/superpowers/specs/2026-08-01-agent-channel-broker-design.md.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import socket
30
+ import subprocess
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime, timedelta, timezone
34
+ from pathlib import Path
35
+ from typing import Callable
36
+
37
+ CHANNEL_DIRNAME = ".agent-channel"
38
+ REGISTRY_FILENAME = "agents.json"
39
+ ROUNDS_DIRNAME = "rounds"
40
+ STATUS_DIRNAME = "status"
41
+
42
+ #: Closed on purpose. An open-ended status string would make the audit report
43
+ #: ungradeable -- it could not tell a state it understands from a typo.
44
+ STATUSES = (
45
+ "open",
46
+ "delivered",
47
+ "acknowledged",
48
+ "blocked",
49
+ "done",
50
+ "withdrawn",
51
+ "superseded",
52
+ )
53
+
54
+ #: Only the broker may assert these; an agent claiming them would defeat the
55
+ #: point of recording them separately from what the agent says it did.
56
+ BROKER_ONLY_STATUSES = frozenset({"open", "delivered", "superseded"})
57
+ RECIPIENT_STATUSES = frozenset({"acknowledged", "blocked", "done"})
58
+ AUTHOR_STATUSES = frozenset({"withdrawn"})
59
+
60
+ _ROUND_IN_NAME = re.compile(r"round-(\d+)")
61
+ _SLUG_STRIP = re.compile(r"[^a-z0-9]+")
62
+ _LOCK_TIMEOUT_SECONDS = 30.0
63
+ # Deliberately under the lock timeout: a git call that outlives the lock wait
64
+ # would strand every other agent behind a holder that is already doomed.
65
+ #
66
+ # Note this holds per CALL, not per critical section. `register_agent` runs
67
+ # `ensure_channel_hook`'s config call plus a three-call `_commit`, so a
68
+ # perfectly healthy holder can legitimately sit for ~80s -- well over the 30s a
69
+ # waiter is willing to wait. A `lock_timeout` is therefore not by itself
70
+ # evidence of a wedge.
71
+ _GIT_TIMEOUT_SECONDS = 20.0
72
+
73
+ #: How long before an unreleased lock is assumed abandoned.
74
+ #:
75
+ #: Staleness is TTL-only, following `buildlock.py` -- read its module docstring
76
+ #: before "improving" this. `os.kill(pid, 0)` is the obvious liveness idiom and
77
+ #: is WRONG here: on Windows, Python's `os.kill` ignores signal 0 and calls
78
+ #: TerminateProcess, so the portable-looking probe kills the very process it is
79
+ #: checking. The `pid` and `host` in the record are DIAGNOSTIC ONLY -- they tell
80
+ #: an operator who to go look at; they are never probed.
81
+ #:
82
+ #: 300s is ~3.7x the ~80s worst-case LEGITIMATE hold derived above. The margin
83
+ #: is lopsided on purpose: breaking a live lock puts two writers in the critical
84
+ #: section, which is the exact failure this lock exists to prevent, and a torn
85
+ #: status directory is worse than a wedge that clears itself five minutes later.
86
+ _LOCK_STALE_SECONDS = 300.0
87
+
88
+ #: Channel content whose orphaned presence changes behaviour. Everything else a
89
+ #: dead holder might leave behind is inert.
90
+ _RESIDUE_PREFIXES = (f"{STATUS_DIRNAME}/", f"{ROUNDS_DIRNAME}/", REGISTRY_FILENAME)
91
+
92
+
93
+ class ChannelError(Exception):
94
+ """A refusal with a machine-readable code, so callers can branch on cause."""
95
+
96
+ def __init__(self, code: str, message: str = "") -> None:
97
+ super().__init__(message or code)
98
+ self.code = code
99
+
100
+
101
+ @dataclass
102
+ class Round:
103
+ number: int | None
104
+ path: Path
105
+ title: str
106
+ body: str = ""
107
+ author: str | None = None
108
+ posted: str | None = None
109
+ to: list[str] = field(default_factory=list)
110
+ supersedes: int | None = None
111
+
112
+ @property
113
+ def legacy(self) -> bool:
114
+ """True when the round predates the broker.
115
+
116
+ These carry no stamped author, and the channel's own history cannot
117
+ supply one either: the 37 relocated rounds were moved in a single commit
118
+ that did not carry history, so git reports graphite as the author of all
119
+ of them regardless of who wrote them. Reporting that as fact would be a
120
+ lie, so the reader marks them and the audit report grades them `legacy`.
121
+ """
122
+ return self.author is None
123
+
124
+
125
+ # --- channel location -------------------------------------------------------
126
+
127
+
128
+ def channel_root() -> Path:
129
+ """Resolve the channel from machine-local config, never from a stored path.
130
+
131
+ An absolute path must not be written into managed instruction files: those
132
+ are committed and pushed in consumer repos, so the operator's directory
133
+ layout would land on their remotes.
134
+ """
135
+ from .config import default_projects_root
136
+
137
+ return (default_projects_root() / CHANNEL_DIRNAME).resolve()
138
+
139
+
140
+ def require_channel(root: Path | None = None) -> Path:
141
+ root = root or channel_root()
142
+ if not root.is_dir():
143
+ raise ChannelError("channel_missing", f"channel not found at {root}")
144
+ if not (root / ".git").exists():
145
+ raise ChannelError(
146
+ "channel_not_git",
147
+ f"channel at {root} is not a git repository, so changes there are unauditable",
148
+ )
149
+ return root
150
+
151
+
152
+ # --- identity ---------------------------------------------------------------
153
+
154
+
155
+ def _normalize(path: Path) -> str:
156
+ """Canonical key for registry lookup.
157
+
158
+ `resolve()` collapses `..`, `.` and trailing separators, and casefold makes
159
+ the lookup survive Windows' case-insensitive paths. Without this the hard
160
+ refusal below would fire on a correct-but-differently-spelled path, which
161
+ would train operators to widen the registry.
162
+ """
163
+ return os.path.normcase(str(Path(path).resolve()))
164
+
165
+
166
+ def read_registry(root: Path) -> dict[str, str]:
167
+ path = root / REGISTRY_FILENAME
168
+ if not path.is_file():
169
+ return {}
170
+ try:
171
+ raw = json.loads(path.read_text(encoding="utf-8"))
172
+ except (OSError, ValueError) as exc:
173
+ raise ChannelError("registry_unreadable", str(exc)) from exc
174
+ if not isinstance(raw, dict):
175
+ raise ChannelError("registry_unreadable", "agents.json is not an object")
176
+ return {_normalize(Path(k)): str(v) for k, v in raw.items()}
177
+
178
+
179
+ def write_registry(root: Path, mapping: dict[str, str]) -> None:
180
+ (root / REGISTRY_FILENAME).write_text(
181
+ json.dumps(mapping, indent=2, sort_keys=True) + "\n", encoding="utf-8"
182
+ )
183
+
184
+
185
+ _AGENT_ID = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*-agent$")
186
+
187
+ #: The channel's `commit-msg` gate, derived from `agents.json` rather than a
188
+ #: hardcoded list.
189
+ #:
190
+ #: The original carried its own allowlist of agent names, which meant
191
+ #: registering a newcomer produced a repo that resolved an identity and was then
192
+ #: rejected on every commit -- registration that lies. Two records that drift is
193
+ #: the failure; there is one record on purpose.
194
+ #:
195
+ #: It also requires the NAME and ADDRESS to be the same agent. The old
196
+ #: alternation `(codex|aramid|graphite)-agent <(codex|aramid|graphite)@...>`
197
+ #: matched the two halves independently, so `codex-agent <aramid@agents.local>`
198
+ #: passed.
199
+ #:
200
+ #: Fails closed: a missing or unparseable registry rejects rather than waving
201
+ #: commits through.
202
+ COMMIT_MSG_HOOK = '''#!/bin/sh
203
+ # Agent channel audit gate. GRAPHITE-MANAGED -- edit via `graphite channel`.
204
+ #
205
+ # Every change here must say WHICH AGENT made it. The committer identity is the
206
+ # operator's for all agents, so without a trailer the history cannot answer
207
+ # "who wrote this" -- which is the whole audit requirement.
208
+ #
209
+ # Rejects rather than warns: a warning on a commit is a warning nobody reads.
210
+ # There is deliberately no --no-verify guidance; if a commit cannot name an
211
+ # agent, it does not belong in the channel.
212
+
213
+ MSG_FILE="$1"
214
+ ROOT="$(git rev-parse --show-toplevel)"
215
+
216
+ # The registry may never be deleted. Authority is read from HEAD (see the
217
+ # Python block below), so a commit that REMOVES `agents.json` would leave the
218
+ # next commit with no committed registry and drop it onto the bootstrap path,
219
+ # where any trailer is accepted. `case` rather than `grep`: this must work with
220
+ # nothing but shell builtins on PATH.
221
+ #
222
+ # This is the specific, well-named refusal, NOT the load-bearing one. Rename
223
+ # detection is on by default, so a commit that renames `agents.json` out of the
224
+ # way is reported `R`, not `D`, and this filter returns nothing at all --
225
+ # measured. The registry check in the Python block is what actually closes the
226
+ # escalation, for deletion, renaming and emptying alike.
227
+ DELETED="$(git -C "$ROOT" diff --cached --diff-filter=D --name-only 2>/dev/null)"
228
+ case "$DELETED" in
229
+ *agents.json*)
230
+ echo "[agent-channel] REJECTED: agents.json may not be deleted." >&2
231
+ echo "" >&2
232
+ echo "The registry is the only thing that says which agents exist, so" >&2
233
+ echo "removing it would disarm the gate for the following commit." >&2
234
+ exit 1
235
+ ;;
236
+ esac
237
+
238
+ # `python3` FIRST, and `python` only as a fallback. Ubuntu ships no `python` at
239
+ # all and macOS removed it in 12.3, so hardcoding it made this gate reject every
240
+ # commit on any non-Windows machine -- an outage, not a rejection. Windows is the
241
+ # single platform where the old spelling worked.
242
+ #
243
+ # `-I` rather than `-P`: both drop the CWD from `sys.path`, but `-P` is 3.11+ and
244
+ # this hook is COMMITTED, so it runs under whatever `python3` a machine happens to
245
+ # have. `-I` has existed since 3.4 and cannot brick the gate on an older one.
246
+ if command -v python3 >/dev/null 2>&1; then
247
+ PY=python3
248
+ elif command -v python >/dev/null 2>&1; then
249
+ PY=python
250
+ else
251
+ # `echo`, not `cat`: this branch fires when the environment is already
252
+ # threadbare, and a builtin needs nothing on PATH to report it.
253
+ echo "[agent-channel] BLOCKED: no Python interpreter found (tried python3, python)." >&2
254
+ echo "" >&2
255
+ echo "The audit gate cannot verify this commit, so it is refused rather than" >&2
256
+ echo "waved through. This is NOT a problem with your commit message. Install" >&2
257
+ echo "Python or put it on PATH, then commit again." >&2
258
+ exit 1
259
+ fi
260
+
261
+ "$PY" -I - "$MSG_FILE" "$ROOT" <<'PYEOF'
262
+ import json, re, subprocess, sys
263
+
264
+ # Distinguishable so the shell can name the ACTUAL reason. Sentinel objects
265
+ # rather than strings: a registry whose contents happened to be `"absent"` would
266
+ # otherwise be able to impersonate one.
267
+ ABSENT = object()
268
+ CORRUPT = object()
269
+
270
+
271
+ def _registry_at(root, spec):
272
+ """Parse `agents.json` at a git revision spec.
273
+
274
+ HEAD is the authority the commit under review cannot edit. Authorising
275
+ against the WORKING TREE was a self-signing hole: the commit being checked
276
+ can write `agents.json`, so adding a row and carrying the matching trailer in
277
+ the same commit satisfied the gate. That turns the design's "identity is
278
+ derived, never declared" straight back into declared.
279
+
280
+ `:agents.json` reads the INDEX -- the prospective commit's own content, which
281
+ is what the next commit will inherit as authority.
282
+ """
283
+ result = subprocess.run(
284
+ ["git", "-C", root, "show", spec],
285
+ capture_output=True,
286
+ text=True,
287
+ # Explicit, for the reason `_git` documents: `text=True` alone decodes
288
+ # with the locale codec, and a registry key is a repository PATH, which
289
+ # is not required to be ASCII. Under cp1252 a non-Latin-1 path would
290
+ # fail to decode on subprocess's reader thread and hand this function a
291
+ # `None` -- wedging the gate over a repo whose only crime is its name.
292
+ encoding="utf-8",
293
+ check=False,
294
+ )
295
+ if result.returncode != 0:
296
+ return ABSENT
297
+ try:
298
+ return json.loads(result.stdout)
299
+ except ValueError:
300
+ return CORRUPT
301
+
302
+
303
+ def _authorises_anyone(value):
304
+ """Whether a parsed registry actually carries authority.
305
+
306
+ Deliberately mirrors the exact condition that arms the bootstrap branch --
307
+ falsiness after `json.loads`, NOT "the file exists". Blocking deletion left
308
+ the identical escalation one keystroke away: `{}` (and `[]`, `null`, `0`,
309
+ `""`, which all parse happily and are all falsy) reaches bootstrap just as a
310
+ missing file does.
311
+ """
312
+ return isinstance(value, dict) and bool(value)
313
+
314
+
315
+ try:
316
+ message = open(sys.argv[1], encoding="utf-8", errors="replace").read()
317
+ root = sys.argv[2]
318
+ head = _registry_at(root, "HEAD:agents.json")
319
+
320
+ if _authorises_anyone(head):
321
+ # The registry must still authorise someone AFTER this commit. Otherwise
322
+ # the next commit finds no committed authority, falls to bootstrap, and
323
+ # reads the working tree -- which that commit writes. One registered
324
+ # agent could disarm the gate permanently for everyone.
325
+ #
326
+ # A "may only grow" rule would be tidier and would be WRONG:
327
+ # `register_agent` drops the old row when a repo is re-registered under a
328
+ # new name, so a superset check rejects a legitimate rename. Narrowing is
329
+ # denial, not impersonation, and still needs a valid trailer. Only the
330
+ # transition to empty hands out authority.
331
+ if not _authorises_anyone(_registry_at(root, ":agents.json")):
332
+ sys.exit(2)
333
+ agents = set(head.values())
334
+ elif head is ABSENT or head == {}:
335
+ # Bootstrap only: nothing is committed, or nothing is registered yet, so
336
+ # there is no prior authority to check against. A freshly seeded channel
337
+ # commits `{}` before anyone is registered, so "the file exists" is not
338
+ # the same question as "anyone is authorised yet" -- testing presence
339
+ # instead locked out the very first registration and every commit after
340
+ # it. This is the one commit whose author is structurally unverifiable;
341
+ # someone has to write the first row.
342
+ with open(root + "/agents.json", encoding="utf-8") as handle:
343
+ agents = set(json.load(handle).values())
344
+ else:
345
+ # Parses, authorises nobody, and is not the empty object a fresh channel
346
+ # seeds. Nothing legitimate produces this, and the gate must not guess.
347
+ sys.exit(3)
348
+ except Exception:
349
+ # `SystemExit` derives from BaseException, so the exits above pass through
350
+ # this handler untouched -- their codes survive.
351
+ sys.exit(1)
352
+
353
+ for agent in agents:
354
+ local = agent[: -len("-agent")] if agent.endswith("-agent") else agent
355
+ pattern = r"^Co-Authored-By: %s <%s@agents\\.local>$" % (
356
+ re.escape(agent),
357
+ re.escape(local),
358
+ )
359
+ if re.search(pattern, message, re.MULTILINE):
360
+ sys.exit(0)
361
+ sys.exit(1)
362
+ PYEOF
363
+ STATUS=$?
364
+
365
+ if [ "$STATUS" -eq 0 ]; then
366
+ exit 0
367
+ fi
368
+
369
+ # Distinct codes rather than one generic rejection. A true rejection with a false
370
+ # REASON sends you to fix something that was already correct -- the same failure
371
+ # the missing-interpreter branch above exists to avoid. The banners below stay
372
+ # QUOTED for the reason given at the generic one.
373
+ if [ "$STATUS" -eq 2 ]; then
374
+ cat >&2 <<'EOF'
375
+ [agent-channel] REJECTED: this commit leaves no usable agent registry.
376
+
377
+ agents.json is the only record of which agents exist. A commit that empties it
378
+ -- to {}, or to a list, string or number that is not an object -- or that
379
+ removes or renames it, would leave the NEXT commit with no committed authority
380
+ to check against, and that commit would fall back to reading the working tree,
381
+ which any commit can write. One registered agent could disarm this gate
382
+ permanently for everyone.
383
+
384
+ This is NOT a problem with your commit message. Rows may be added, and a repo
385
+ may be re-registered under a new name; the last one may not be removed.
386
+ EOF
387
+ exit 1
388
+ fi
389
+
390
+ if [ "$STATUS" -eq 3 ]; then
391
+ cat >&2 <<'EOF'
392
+ [agent-channel] BLOCKED: the committed agent registry is unusable.
393
+
394
+ agents.json at HEAD parses but is not an object of rows, so the gate cannot tell
395
+ which agents exist and refuses every commit rather than guessing. This is NOT a
396
+ problem with your commit message, and `channel register` cannot repair it --
397
+ that command commits, so it is refused here too.
398
+
399
+ An operator must restore agents.json to {"<repo path>": "<name>-agent"} rows and
400
+ commit it with `git commit --no-verify`. That flag is named here and nowhere
401
+ else in this gate: a rejected trailer has a real fix, whereas this branch has no
402
+ other recovery.
403
+ EOF
404
+ exit 1
405
+ fi
406
+
407
+ # The banner heredocs stay QUOTED. An earlier revision unquoted them to
408
+ # interpolate `$PY`, which turned the audit gate's own output into a shell
409
+ # expansion surface: any `$`, backtick or `$(...)` that later reaches this text
410
+ # -- an interpolated agent name, path or commit subject -- would be executed by
411
+ # the script that exists to gate commits. Nothing was exploitable while the body
412
+ # stayed static, which is exactly why it would have survived review. The one
413
+ # value actually needed is printed separately instead.
414
+ cat >&2 <<'EOF'
415
+ [agent-channel] REJECTED: this commit names no agent.
416
+
417
+ Every commit in the channel must carry the trailer of the agent that wrote it,
418
+ so history can answer "who changed this, and why":
419
+
420
+ Co-Authored-By: <name>-agent <name@agents.local>
421
+
422
+ Only your own, and only an agent registered in agents.json. Register one with:
423
+
424
+ EOF
425
+ # `-I`, the same flag the guard above uses -- NOT `-P`. `-P` is 3.11+, so on the
426
+ # older interpreters `-I` exists to support, this printed command would fail with
427
+ # `Unknown option: -P`, and the natural operator recovery is to delete the flag.
428
+ # That lands on `python3 -m graphite ...` run from a repo root, which is the
429
+ # CWD-shadowing path the flag was added to close. Advice that breaks into an
430
+ # insecure form under the reader's hand is worse than no advice.
431
+ printf ' %s -I -m graphite channel register <repo-path> <name>-agent\n\n' "$PY" >&2
432
+ cat >&2 <<'EOF'
433
+ The commit message must also state the reason for the change.
434
+ See PROTOCOL.md, "Audit requirements".
435
+ EOF
436
+ exit 1
437
+ '''
438
+
439
+
440
+ def ensure_channel_hook(root: Path) -> dict:
441
+ """Install/refresh the audit gate and point `core.hooksPath` at it."""
442
+ hooks = root / ".githooks"
443
+ hooks.mkdir(parents=True, exist_ok=True)
444
+ path = hooks / "commit-msg"
445
+ path.write_text(COMMIT_MSG_HOOK, encoding="utf-8", newline="\n")
446
+ path.chmod(0o755)
447
+ _git(root, "config", "core.hooksPath", ".githooks", check=False)
448
+ return {"path": str(path), "changed": True}
449
+
450
+
451
+ def _committed_agents(root: Path) -> set[str]:
452
+ """Agent identities as of HEAD -- the authority the gate checks against.
453
+
454
+ Read from committed state for exactly the reason the hook does: the
455
+ working-tree registry is about to be rewritten by this very call, so it
456
+ cannot be the thing that authorises the change.
457
+ """
458
+ raw = _git(root, "show", f"HEAD:{REGISTRY_FILENAME}", check=False)
459
+ try:
460
+ loaded = json.loads(raw)
461
+ except ValueError:
462
+ return set()
463
+ return {str(value) for value in loaded.values()} if isinstance(loaded, dict) else set()
464
+
465
+
466
+ def register_agent(root: Path, project_root: Path, agent: str) -> dict:
467
+ """Bind a repository to an agent identity, and commit the change.
468
+
469
+ This is the operator-side half the broker shipped without. `init` writes
470
+ `.mcp.json` and doctrine telling an agent to call `channel_inbox`, but an
471
+ unregistered repo is refused -- so without a registration command a newly
472
+ onboarded agent is told to use tools that reject it, and the only remedy is
473
+ hand-editing JSON in a repo it cannot reach. "Ask the operator" is not a
474
+ mechanism; this is.
475
+ """
476
+ root = require_channel(root)
477
+ if not isinstance(agent, str) or not _AGENT_ID.match(agent):
478
+ raise ChannelError(
479
+ "invalid_agent_id",
480
+ f"{agent!r} must look like `name-agent` (lowercase, hyphen-separated)",
481
+ )
482
+
483
+ with _Lock(root) as lock:
484
+ registry_path = root / REGISTRY_FILENAME
485
+ raw: dict[str, str] = {}
486
+ if registry_path.is_file():
487
+ try:
488
+ loaded = json.loads(registry_path.read_text(encoding="utf-8"))
489
+ except ValueError as exc:
490
+ raise ChannelError("registry_unreadable", str(exc)) from exc
491
+ if isinstance(loaded, dict):
492
+ raw = {str(k): str(v) for k, v in loaded.items()}
493
+
494
+ key = Path(project_root).resolve().as_posix()
495
+ previous = None
496
+ for existing in list(raw):
497
+ if _normalize(Path(existing)) == _normalize(Path(project_root)):
498
+ previous = raw.pop(existing)
499
+ raw[key] = agent
500
+ write_registry(root, raw)
501
+
502
+ # The gate must be able to accept the newcomer, or this registration is
503
+ # a lie. Refreshing it here keeps the two in step by construction.
504
+ ensure_channel_hook(root)
505
+
506
+ # Who signs this registration is now decided by COMMITTED authority, not
507
+ # by the registry we just wrote. The old test was `"graphite-agent" in
508
+ # raw.values()` -- `raw` being the NEW registry -- which meant a second
509
+ # registration in a channel without graphite credited the newcomer, and
510
+ # the newcomer is by definition not yet authorised. That was invisible
511
+ # while the gate read the working tree, because the row it was checking
512
+ # against was the one this call had just written.
513
+ committed = _committed_agents(root)
514
+ if not committed:
515
+ # Bootstrap: nobody is registered, so there is no authority to
516
+ # borrow. Signed by whoever it registers -- the one commit that is
517
+ # structurally unverifiable, and unavoidable: someone writes row one.
518
+ author = agent
519
+ elif "graphite-agent" in committed:
520
+ # Graphite made this change, so graphite is credited. Attributing it
521
+ # to the agent being registered would read as "codex registered
522
+ # itself", which is false and is the kind of claim the trailer
523
+ # exists to keep honest.
524
+ author = "graphite-agent"
525
+ else:
526
+ # The broker mutates authority state, so it must hold authority
527
+ # itself. Failing loudly beats signing as someone else or writing a
528
+ # commit the gate will refuse for reasons the caller cannot see.
529
+ raise ChannelError(
530
+ "broker_unregistered",
531
+ "graphite-agent is not registered in this channel, so it cannot "
532
+ "author a registry change. Register it first: "
533
+ "`graphite channel register <graphite-repo> graphite-agent`",
534
+ )
535
+
536
+ commit = _commit(
537
+ root,
538
+ [REGISTRY_FILENAME, ".githooks/commit-msg"],
539
+ f"register {agent} for {Path(project_root).name}",
540
+ author,
541
+ )
542
+
543
+ result = {"ok": True, "agent": agent, "path": key, "previous": previous, "commit": commit}
544
+ if lock.recovered is not None:
545
+ result["lock_recovered"] = lock.recovered
546
+ return result
547
+
548
+
549
+ def derive_identity(root: Path, project_root: Path) -> str:
550
+ """Map the repo the broker runs in to an agent identity, or refuse."""
551
+ registry = read_registry(root)
552
+ key = _normalize(project_root)
553
+ agent = registry.get(key)
554
+ if agent is None:
555
+ # Name the command, not the file. "Add it to agents.json" is advice a
556
+ # sandboxed agent cannot act on -- the channel is the one place it
557
+ # cannot reach, which is the whole reason the broker exists.
558
+ raise ChannelError(
559
+ "unregistered_project",
560
+ f"{project_root} is not a registered agent repository. "
561
+ f"Ask the operator to run: graphite channel register {project_root} <name>-agent",
562
+ )
563
+ return agent
564
+
565
+
566
+ def agent_email(agent: str) -> str:
567
+ """`aramid-agent` -> `aramid@agents.local`, matching the channel's hook."""
568
+ return f"{agent.removesuffix('-agent')}@agents.local"
569
+
570
+
571
+ def trailer(agent: str) -> str:
572
+ return f"Co-Authored-By: {agent} <{agent_email(agent)}>"
573
+
574
+
575
+ # --- round documents --------------------------------------------------------
576
+
577
+
578
+ def _slug(text: str) -> str:
579
+ slug = _SLUG_STRIP.sub("-", text.lower()).strip("-")
580
+ return slug[:60] or "round"
581
+
582
+
583
+ def parse_round(text: str) -> tuple[dict, str]:
584
+ """Parse the flat front matter block.
585
+
586
+ Hand-rolled rather than YAML on purpose: the schema is closed and flat, so a
587
+ parser with no dependency and no surprises (`to: [a]` vs `to: a`) is worth
588
+ more here than generality.
589
+ """
590
+ meta: dict = {}
591
+ if not text.startswith("---\n"):
592
+ return meta, text
593
+ _, _, rest = text.partition("---\n")
594
+ block, sep, body = rest.partition("\n---\n")
595
+ if not sep:
596
+ return {}, text
597
+ for line in block.splitlines():
598
+ key, _, value = line.partition(":")
599
+ key, value = key.strip(), value.strip()
600
+ if not key:
601
+ continue
602
+ if key in {"round", "supersedes"}:
603
+ meta[key] = int(value) if value.isdigit() else None
604
+ elif key == "to":
605
+ meta[key] = [part.strip() for part in value.split(",") if part.strip()]
606
+ else:
607
+ meta[key] = value
608
+ return meta, body.lstrip("\n")
609
+
610
+
611
+ def render_round(meta: dict, body: str) -> str:
612
+ lines = ["---"]
613
+ for key in ("round", "author", "posted", "title", "to", "supersedes"):
614
+ value = meta.get(key)
615
+ if value is None or value == [] or value == "":
616
+ continue
617
+ if isinstance(value, list):
618
+ value = ", ".join(value)
619
+ lines.append(f"{key}: {value}")
620
+ lines.append("---")
621
+ return "\n".join(lines) + "\n\n" + body.rstrip("\n") + "\n"
622
+
623
+
624
+ def _load_round(path: Path) -> Round:
625
+ text = path.read_text(encoding="utf-8", errors="replace")
626
+ meta, body = parse_round(text)
627
+ number = meta.get("round")
628
+ if number is None:
629
+ match = _ROUND_IN_NAME.search(path.name)
630
+ number = int(match.group(1)) if match else None
631
+ title = meta.get("title") or _title_from_body(body or text) or path.stem
632
+ return Round(
633
+ number=number,
634
+ path=path,
635
+ title=title,
636
+ body=body or text,
637
+ author=meta.get("author"),
638
+ posted=meta.get("posted"),
639
+ to=list(meta.get("to") or []),
640
+ supersedes=meta.get("supersedes"),
641
+ )
642
+
643
+
644
+ def _title_from_body(body: str) -> str | None:
645
+ for line in body.splitlines():
646
+ if line.startswith("#"):
647
+ return line.lstrip("#").strip() or None
648
+ return None
649
+
650
+
651
+ def list_rounds(root: Path) -> list[Round]:
652
+ rounds_dir = root / ROUNDS_DIRNAME
653
+ if not rounds_dir.is_dir():
654
+ return []
655
+ loaded = [_load_round(p) for p in sorted(rounds_dir.glob("*.md"))]
656
+ return sorted(loaded, key=lambda r: (r.number is None, r.number or 0, r.path.name))
657
+
658
+
659
+ def read_round(root: Path, number: int) -> Round:
660
+ for entry in list_rounds(root):
661
+ if entry.number == number:
662
+ return entry
663
+ raise ChannelError("round_not_found", f"no round {number}")
664
+
665
+
666
+ def next_round_number(root: Path) -> int:
667
+ numbers = [r.number for r in list_rounds(root) if r.number is not None]
668
+ return (max(numbers) + 1) if numbers else 1
669
+
670
+
671
+ # --- git --------------------------------------------------------------------
672
+
673
+
674
+ def _git(root: Path, *args: str, check: bool = True) -> str:
675
+ """Run git for the channel, bounded and detached from the caller's stdin.
676
+
677
+ Both guards exist because this runs inside `_Lock`, where a stall is not one
678
+ caller's problem but a channel-wide outage that no timeout elsewhere can
679
+ clear:
680
+
681
+ - `stdin=DEVNULL`: the broker is a stdio MCP server, so its stdin is the
682
+ JSON-RPC pipe. A child that inherits it can block on it, and anything it
683
+ consumes is protocol the server never sees.
684
+ - `timeout`: a hung git otherwise holds the lock until someone kills it by
685
+ hand. Observed live -- `git add` blocked 45 minutes at 0.03s CPU, having
686
+ never reached `.git/index.lock`, with every other agent locked out behind
687
+ it.
688
+
689
+ The encoding is EXPLICIT and must stay that way. `text=True` alone decodes
690
+ with the locale codec -- cp1252 on Windows -- and rounds are UTF-8 prose
691
+ written by agents, so any em-dash or accented character is a byte cp1252
692
+ does not define. The failure that causes is worse than an exception:
693
+ subprocess reads the pipe on a worker thread, the `UnicodeDecodeError` is
694
+ raised THERE, and `result.stdout` comes back `None` while git's own
695
+ returncode stays 0 -- so `check` below sees nothing wrong and this function
696
+ returns `None` against a `str` annotation. Observed live: `channel report`
697
+ died in `parse_round`, three frames away from the real cause.
698
+
699
+ `errors="replace"` because git also echoes paths, and a byte sequence that
700
+ is not valid UTF-8 must not be able to take the audit surface down. It also
701
+ makes the decode total, which is what keeps `result.stdout` a `str`.
702
+ """
703
+ try:
704
+ result = subprocess.run( # noqa: S603
705
+ ["git", "-C", str(root), *args], # noqa: S607
706
+ capture_output=True,
707
+ text=True,
708
+ encoding="utf-8",
709
+ errors="replace",
710
+ check=False,
711
+ stdin=subprocess.DEVNULL,
712
+ timeout=_GIT_TIMEOUT_SECONDS,
713
+ )
714
+ except subprocess.TimeoutExpired:
715
+ raise ChannelError(
716
+ "git_timeout",
717
+ f"git {' '.join(args)} exceeded {_GIT_TIMEOUT_SECONDS:g}s in {root}",
718
+ ) from None
719
+ if check and result.returncode != 0:
720
+ raise ChannelError("git_failed", (result.stderr or result.stdout).strip())
721
+ return result.stdout
722
+
723
+
724
+ def _commit(root: Path, paths: list[str], subject: str, agent: str) -> str:
725
+ message = f"{subject}\n\n{trailer(agent)}\n"
726
+ _git(root, "add", "--", *paths)
727
+ _git(root, "commit", "-q", "-m", message)
728
+ return _git(root, "rev-parse", "--short", "HEAD").strip()
729
+
730
+
731
+ def _uncommitted_residue(root: Path) -> list[str]:
732
+ """Channel files an abandoned holder left behind uncommitted.
733
+
734
+ Automatic recovery makes this MORE important, not less. `status_events`
735
+ reads from DISK, so an orphaned `status/NNN/*.json` written by a holder that
736
+ died before committing silently suppresses redelivery of that round. While
737
+ clearing the lock was a manual act it came with an instruction to go and
738
+ look; once the lock clears itself, nobody is looking unless we say so.
739
+
740
+ Best-effort by construction: this runs while we hold nothing, and a failure
741
+ to scan must never block a recovery that is otherwise safe.
742
+ """
743
+ try:
744
+ # `--untracked-files=all`: bare `--porcelain` collapses a wholly
745
+ # untracked directory to `status/`, which is precisely the filename an
746
+ # operator needs in order to go and look at it.
747
+ porcelain = _git(root, "status", "--porcelain", "--untracked-files=all", check=False)
748
+ except ChannelError:
749
+ return []
750
+ residue = set()
751
+ for line in porcelain.splitlines():
752
+ path = line[3:].strip().strip('"')
753
+ if path.startswith(_RESIDUE_PREFIXES):
754
+ residue.add(path)
755
+ return sorted(residue)
756
+
757
+
758
+ class _Lock:
759
+ """Exclusive lock so concurrent allocations cannot collide on a number.
760
+
761
+ Create-only writes keep the critical section tiny -- allocate, write, commit
762
+ -- because nothing is ever read-modify-written.
763
+
764
+ A holder that is *killed* never runs `__exit__`, and the lock it leaves has
765
+ no expiry of its own: bounding `_git` covers a holder that stalls, not one
766
+ that dies. So the file carries `{pid, started_at, host}` and is broken once
767
+ it passes `_LOCK_STALE_SECONDS`.
768
+ """
769
+
770
+ def __init__(
771
+ self,
772
+ root: Path,
773
+ *,
774
+ clock: Callable[[], float] = time.time,
775
+ stale_seconds: float = _LOCK_STALE_SECONDS,
776
+ ) -> None:
777
+ self.root = root
778
+ self.path = root / ".channel.lock"
779
+ # `time.time`, never `time.monotonic`: the deadline below is monotonic
780
+ # and it is right there to copy, but monotonic values are meaningless
781
+ # across processes, which is the only comparison staleness makes.
782
+ self._clock = clock
783
+ self._stale_seconds = stale_seconds
784
+ self._record: dict | None = None
785
+ #: Set when this acquisition had to break an abandoned lock. Callers
786
+ #: surface it, because a silent recovery would hide both the crash and
787
+ #: whatever the dead holder left half-written.
788
+ self.recovered: dict | None = None
789
+
790
+ def _read_record(self) -> dict | None:
791
+ """The holder's record, or None if absent, unreadable, or not a dict."""
792
+ try:
793
+ loaded = json.loads(self.path.read_text(encoding="utf-8"))
794
+ except (OSError, ValueError):
795
+ return None
796
+ return loaded if isinstance(loaded, dict) else None
797
+
798
+ @staticmethod
799
+ def _identity(record: dict | None) -> tuple | None:
800
+ return None if record is None else (record.get("pid"), record.get("started_at"))
801
+
802
+ def _is_stale(self, record: dict | None) -> bool:
803
+ started = None if record is None else record.get("started_at")
804
+ if not isinstance(started, (int, float)) or isinstance(started, bool):
805
+ # No usable timestamp: absent, garbage, or a record still being
806
+ # written. Fall back to the file's own mtime rather than declaring
807
+ # it stale outright. `_acquire` creates the file with O_EXCL and
808
+ # writes the record immediately after, so "created, not yet written"
809
+ # is a normal state on the ORDINARY path -- calling unparseable
810
+ # content instantly stale would let a competing waiter break a lock
811
+ # that had just been legitimately taken, on every contended
812
+ # acquisition rather than only in some rare recovery. Garbage still
813
+ # ages out; it just ages out by the clock rather than at sight.
814
+ try:
815
+ started = self.path.stat().st_mtime
816
+ except OSError:
817
+ return True # already gone: there is nothing left to protect
818
+ return (self._clock() - float(started)) > self._stale_seconds
819
+
820
+ def _acquire(self) -> bool:
821
+ try:
822
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
823
+ except FileExistsError:
824
+ return False
825
+ record = {"pid": os.getpid(), "started_at": float(self._clock()), "host": socket.gethostname()}
826
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
827
+ json.dump(record, handle)
828
+ self._record = record
829
+ return True
830
+
831
+ def _break_if_abandoned(self) -> bool:
832
+ """Remove an abandoned lock. True when this call removed one.
833
+
834
+ The record is read TWICE and both reads must agree. Several agents
835
+ wedged behind one dead holder all judge it stale at the same moment; the
836
+ first breaker unlinks and immediately re-creates the lock for itself,
837
+ and without the confirming read every other breaker would then unlink
838
+ that FRESH lock in turn and all of them would enter the critical section
839
+ together. Two writers here is the failure the lock exists to prevent.
840
+
841
+ What survives is a single-syscall window between the confirming read and
842
+ the unlink -- the same residual `buildlock` already ships with, and it
843
+ is reachable only if the holder we just judged dead releases within it.
844
+ """
845
+ first = self._read_record()
846
+ if not self._is_stale(first):
847
+ return False
848
+ if self._identity(self._read_record()) != self._identity(first):
849
+ return False
850
+ try:
851
+ self.path.unlink()
852
+ except OSError:
853
+ return False
854
+ held_for = None
855
+ started = (first or {}).get("started_at")
856
+ if isinstance(started, (int, float)) and not isinstance(started, bool):
857
+ held_for = round(self._clock() - float(started), 1)
858
+ self.recovered = {
859
+ "pid": (first or {}).get("pid"),
860
+ "host": (first or {}).get("host"),
861
+ "held_seconds": held_for,
862
+ "residue": _uncommitted_residue(self.root),
863
+ }
864
+ return True
865
+
866
+ def __enter__(self) -> _Lock:
867
+ deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
868
+ while True:
869
+ if self._acquire():
870
+ return self
871
+ if self._break_if_abandoned() and self._acquire():
872
+ return self
873
+ if time.monotonic() >= deadline:
874
+ held = self._identity(self._read_record())
875
+ raise ChannelError(
876
+ "lock_timeout",
877
+ f"channel lock held at {self.path} by {held}",
878
+ ) from None
879
+ time.sleep(0.05)
880
+
881
+ def __exit__(self, *_exc: object) -> None:
882
+ # Release only what we still hold. If a break ever does overlap two
883
+ # holders, unlinking someone else's lock on the way out would turn that
884
+ # single overlap into a cascade.
885
+ if self._identity(self._read_record()) != self._identity(self._record):
886
+ return
887
+ try:
888
+ self.path.unlink()
889
+ except FileNotFoundError:
890
+ pass
891
+
892
+
893
+ def _now() -> str:
894
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
895
+
896
+
897
+ # --- posting ----------------------------------------------------------------
898
+
899
+
900
+ def post_round(
901
+ root: Path,
902
+ project_root: Path,
903
+ *,
904
+ title: str,
905
+ body: str,
906
+ to: list[str] | tuple[str, ...] = (),
907
+ supersedes: int | None = None,
908
+ _force_number: int | None = None,
909
+ ) -> dict:
910
+ """Create a round. There is deliberately no `author` parameter.
911
+
912
+ The caller supplies content, never identity and never a path: the author is
913
+ derived and the filename is generated, which removes both forgery and path
914
+ traversal as classes rather than filtering for them.
915
+ """
916
+ root = require_channel(root)
917
+ agent = derive_identity(root, project_root)
918
+ if not title.strip():
919
+ raise ChannelError("empty_title", "a round needs a title")
920
+
921
+ with _Lock(root) as lock:
922
+ number = _force_number if _force_number is not None else next_round_number(root)
923
+ stamped = _now()
924
+ name = f"{stamped[:10]}-{agent.removesuffix('-agent')}-round-{number}-{_slug(title)}.md"
925
+ target = root / ROUNDS_DIRNAME / name
926
+ target.parent.mkdir(parents=True, exist_ok=True)
927
+ if target.exists() or any(
928
+ r.number == number for r in list_rounds(root) if _force_number is not None
929
+ ):
930
+ raise ChannelError("round_exists", f"round {number} already exists")
931
+ meta = {
932
+ "round": number,
933
+ "author": agent,
934
+ "posted": stamped,
935
+ "title": title.strip(),
936
+ "to": list(to),
937
+ "supersedes": supersedes,
938
+ }
939
+ target.write_text(render_round(meta, body), encoding="utf-8")
940
+ commit = _commit(
941
+ root,
942
+ [f"{ROUNDS_DIRNAME}/{name}"],
943
+ f"round {number}: {title.strip()}",
944
+ agent,
945
+ )
946
+
947
+ result = {
948
+ "ok": True,
949
+ "round": number,
950
+ "path": str(target),
951
+ "author": agent,
952
+ "commit": commit,
953
+ "posted": stamped,
954
+ }
955
+ # Only present when it happened. A key on every response would be noise,
956
+ # and this one has to read as an event.
957
+ if lock.recovered is not None:
958
+ result["lock_recovered"] = lock.recovered
959
+ return result
960
+
961
+
962
+ # --- status: an append-only event log ---------------------------------------
963
+
964
+
965
+ def _status_dir(root: Path, number: int) -> Path:
966
+ return root / STATUS_DIRNAME / f"{number:03d}"
967
+
968
+
969
+ def status_events(root: Path, number: int) -> list[dict]:
970
+ """Every event ever recorded for a round, oldest first.
971
+
972
+ A malformed event is surfaced rather than skipped: silently dropping it
973
+ would let a hand-written file remove a status from the audit trail, which is
974
+ precisely the tampering the report exists to catch.
975
+ """
976
+ directory = _status_dir(root, number)
977
+ if not directory.is_dir():
978
+ return []
979
+ events: list[dict] = []
980
+ for path in sorted(directory.glob("*.json")):
981
+ try:
982
+ payload = json.loads(path.read_text(encoding="utf-8"))
983
+ except (OSError, ValueError):
984
+ payload = {"status": None, "malformed": True}
985
+ payload["file"] = path.name
986
+ events.append(payload)
987
+ return events
988
+
989
+
990
+ def current_status(root: Path, number: int) -> dict | None:
991
+ """Fold the log: the last event wins. `None` means nothing has happened."""
992
+ events = status_events(root, number)
993
+ return events[-1] if events else None
994
+
995
+
996
+ def _write_status_event(
997
+ root: Path,
998
+ number: int,
999
+ status: str,
1000
+ actor: str,
1001
+ *,
1002
+ broker: bool,
1003
+ reason: str | None = None,
1004
+ ) -> dict:
1005
+ with _Lock(root) as lock:
1006
+ directory = _status_dir(root, number)
1007
+ directory.mkdir(parents=True, exist_ok=True)
1008
+ seq = len(list(directory.glob("*.json"))) + 1
1009
+ name = f"{seq:04d}-{status}.json"
1010
+ target = directory / name
1011
+ if target.exists():
1012
+ raise ChannelError("status_exists", f"{name} already exists")
1013
+ event = {
1014
+ "round": number,
1015
+ "seq": seq,
1016
+ "status": status,
1017
+ "actor": actor,
1018
+ # True marks an event the BROKER wrote, not one the agent asserted.
1019
+ # The audit report leans on this to tell "we handed it over" apart
1020
+ # from "the agent says it acted".
1021
+ "broker": broker,
1022
+ "at": _now(),
1023
+ "reason": reason,
1024
+ }
1025
+ target.write_text(json.dumps(event, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1026
+ # Committed under the actor's trailer so the report can compare the two
1027
+ # and catch an event whose recorded actor is not who committed it.
1028
+ event["commit"] = _commit(
1029
+ root,
1030
+ [f"{STATUS_DIRNAME}/{number:03d}/{name}"],
1031
+ f"round {number}: {status}",
1032
+ actor,
1033
+ )
1034
+ # Added after the write, like `commit` above, so it stays out of the on-disk
1035
+ # event: this describes the acquisition, not what was recorded.
1036
+ if lock.recovered is not None:
1037
+ event["lock_recovered"] = lock.recovered
1038
+ return event
1039
+
1040
+
1041
+ def record_status(
1042
+ root: Path,
1043
+ project_root: Path,
1044
+ number: int,
1045
+ status: str,
1046
+ *,
1047
+ reason: str | None = None,
1048
+ ) -> dict:
1049
+ """Record an agent-asserted status.
1050
+
1051
+ Authorization is strict; transitions are not. `done` with no preceding
1052
+ `delivered` is allowed through and flagged by the report -- refusing it
1053
+ would lose the evidence that it happened, and real workflows outrun any
1054
+ state machine written in advance.
1055
+ """
1056
+ root = require_channel(root)
1057
+ agent = derive_identity(root, project_root)
1058
+
1059
+ if status not in STATUSES:
1060
+ raise ChannelError("unknown_status", f"{status!r} is not one of {', '.join(STATUSES)}")
1061
+ if status in BROKER_ONLY_STATUSES:
1062
+ raise ChannelError(
1063
+ "broker_only_status",
1064
+ f"{status!r} is recorded by the broker, not asserted by an agent",
1065
+ )
1066
+
1067
+ entry = read_round(root, number)
1068
+ if status in RECIPIENT_STATUSES and agent not in entry.to:
1069
+ raise ChannelError("not_recipient", f"round {number} is not addressed to {agent}")
1070
+ if status in AUTHOR_STATUSES and agent != entry.author:
1071
+ raise ChannelError("not_author", f"round {number} was not written by {agent}")
1072
+
1073
+ return _write_status_event(root, number, status, agent, broker=False, reason=reason)
1074
+
1075
+
1076
+ # --- inbox: notification and handover ---------------------------------------
1077
+
1078
+
1079
+ def inbox(root: Path, project_root: Path) -> list[Round]:
1080
+ """Hand over rounds addressed to the caller that it has not been given yet.
1081
+
1082
+ The `delivered` event is written HERE, by the broker, as the message goes
1083
+ out. The agent cannot assert it and cannot decline it, so an agent that
1084
+ ignores its inbox leaves a visible `delivered` with no follow-up rather than
1085
+ an absence indistinguishable from never having been told.
1086
+ """
1087
+ root = require_channel(root)
1088
+ agent = derive_identity(root, project_root)
1089
+
1090
+ pending: list[tuple[int, Round]] = []
1091
+ for entry in list_rounds(root):
1092
+ if entry.number is None or agent not in entry.to:
1093
+ continue
1094
+ already = any(
1095
+ event.get("status") == "delivered" and event.get("actor") == agent
1096
+ for event in status_events(root, entry.number)
1097
+ )
1098
+ if not already:
1099
+ pending.append((entry.number, entry))
1100
+
1101
+ for number, _entry in pending:
1102
+ _write_status_event(root, number, "delivered", agent, broker=True)
1103
+ return [entry for _number, entry in pending]
1104
+
1105
+
1106
+ # --- audit report -----------------------------------------------------------
1107
+
1108
+ STALE_DAYS_DEFAULT = 3
1109
+
1110
+ #: Grades that mean the broker cannot vouch for the row. `legacy` is absent on
1111
+ #: purpose: 37 rounds predate the broker and always will, so counting them would
1112
+ #: leave the check permanently red -- and a check that is always red is one
1113
+ #: nobody reads.
1114
+ FAILING_VERIFICATIONS = frozenset({"uncommitted", "modified", "discrepancy"})
1115
+
1116
+ _TRAILER = re.compile(r"^Co-Authored-By:\s*(\S+-agent)\s*<", re.MULTILINE)
1117
+
1118
+
1119
+ def _tracked(root: Path) -> set[str]:
1120
+ return {line.strip() for line in _git(root, "ls-files").splitlines() if line.strip()}
1121
+
1122
+
1123
+ def _commits_for(root: Path, rel: str) -> list[str]:
1124
+ out = _git(root, "log", "--format=%H", "--", rel)
1125
+ return [line.strip() for line in out.splitlines() if line.strip()]
1126
+
1127
+
1128
+ def _commit_agent(root: Path, sha: str) -> str | None:
1129
+ match = _TRAILER.search(_git(root, "show", "-s", "--format=%B", sha))
1130
+ return match.group(1) if match else None
1131
+
1132
+
1133
+ def _dirty(root: Path) -> set[str]:
1134
+ """Tracked files with uncommitted working-tree changes.
1135
+
1136
+ Without this, editing a tracked round and simply not committing would grade
1137
+ `verified`: the file is tracked and still has exactly one commit.
1138
+ """
1139
+ out = _git(root, "status", "--porcelain", "--untracked-files=no")
1140
+ return {line[3:].strip().strip('"') for line in out.splitlines() if line.strip()}
1141
+
1142
+
1143
+ def _verify(root: Path, rel: str, tracked: set[str], dirty: set[str]) -> str:
1144
+ """Grade one file against its ORIGINAL committed content, not its current text.
1145
+
1146
+ Grading the working-tree copy was a hole I shipped into the first draft and
1147
+ the tests caught: overwriting a brokered round erases its front matter, so
1148
+ the author reads as `None`, and the row would grade `legacy` -- which is the
1149
+ one degraded grade that deliberately does not fail the check. Tampering
1150
+ would have laundered itself into "predates the broker".
1151
+
1152
+ Reading the author out of the file's first commit removes that move: what
1153
+ the round was when it was created is not something a later edit can change.
1154
+ """
1155
+ if rel not in tracked:
1156
+ return "uncommitted"
1157
+ if rel in dirty:
1158
+ return "modified"
1159
+ commits = _commits_for(root, rel)
1160
+ if not commits:
1161
+ return "uncommitted"
1162
+ created = commits[-1]
1163
+ original, _ = parse_round(_git(root, "show", f"{created}:{rel}", check=False))
1164
+ origin_author = original.get("author")
1165
+ if origin_author is None:
1166
+ # No stamped author at creation: this predates the broker. Its extra
1167
+ # commits are ordinary history, and nothing here can tell that apart
1168
+ # from tampering -- so say `legacy` rather than guess.
1169
+ return "legacy"
1170
+ if len(commits) > 1:
1171
+ return "modified"
1172
+ if _commit_agent(root, created) != origin_author:
1173
+ return "discrepancy"
1174
+ return "verified"
1175
+
1176
+
1177
+ def _parse_at(value: str | None) -> datetime | None:
1178
+ try:
1179
+ return datetime.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1180
+ except (TypeError, ValueError):
1181
+ return None
1182
+
1183
+
1184
+ def build_report(
1185
+ root: Path,
1186
+ *,
1187
+ stale_days: int = STALE_DAYS_DEFAULT,
1188
+ now: datetime | None = None,
1189
+ ) -> dict:
1190
+ """Assemble the audit view. Every row states what the broker can vouch for."""
1191
+ root = require_channel(root)
1192
+ now = now or datetime.now(timezone.utc)
1193
+ tracked = _tracked(root)
1194
+ dirty = _dirty(root)
1195
+ entries = list_rounds(root)
1196
+ numbers = {e.number for e in entries if e.number is not None}
1197
+
1198
+ rows: list[dict] = []
1199
+ anomalies: list[dict] = []
1200
+ for entry in entries:
1201
+ rel = entry.path.relative_to(root).as_posix()
1202
+ verification = _verify(root, rel, tracked, dirty)
1203
+ events = status_events(root, entry.number) if entry.number is not None else []
1204
+ current = events[-1] if events else None
1205
+
1206
+ stalled = False
1207
+ if current and current.get("status") in {"delivered", "acknowledged"}:
1208
+ at = _parse_at(current.get("at"))
1209
+ stalled = at is not None and (now - at) > timedelta(days=stale_days)
1210
+
1211
+ participants = {entry.author, *entry.to} - {None}
1212
+ for event in events:
1213
+ number = entry.number
1214
+ if event.get("malformed"):
1215
+ anomalies.append({"round": number, "kind": "malformed_status", "detail": event.get("file")})
1216
+ continue
1217
+ actor = event.get("actor")
1218
+ if actor not in participants:
1219
+ anomalies.append(
1220
+ {"round": number, "kind": "status_actor_not_participant", "detail": actor}
1221
+ )
1222
+ event_rel = f"{STATUS_DIRNAME}/{number:03d}/{event['file']}"
1223
+ if event_rel not in tracked:
1224
+ anomalies.append({"round": number, "kind": "status_uncommitted", "detail": event["file"]})
1225
+ seen = [e.get("status") for e in events]
1226
+ if "done" in seen and "delivered" not in seen[: seen.index("done")]:
1227
+ anomalies.append({"round": entry.number, "kind": "done_without_delivery", "detail": None})
1228
+ if entry.supersedes is not None and entry.supersedes not in numbers:
1229
+ anomalies.append(
1230
+ {"round": entry.number, "kind": "supersedes_missing", "detail": entry.supersedes}
1231
+ )
1232
+
1233
+ rows.append(
1234
+ {
1235
+ "round": entry.number,
1236
+ "path": rel,
1237
+ "title": entry.title,
1238
+ "author": entry.author,
1239
+ "to": entry.to,
1240
+ "posted": entry.posted,
1241
+ "supersedes": entry.supersedes,
1242
+ "verification": verification,
1243
+ "status": current.get("status") if current else None,
1244
+ "status_actor": current.get("actor") if current else None,
1245
+ "status_at": current.get("at") if current else None,
1246
+ "reason": current.get("reason") if current else None,
1247
+ "stalled": stalled,
1248
+ }
1249
+ )
1250
+
1251
+ by_agent: dict[str, dict[str, int]] = {}
1252
+ for row in rows:
1253
+ bucket = by_agent.setdefault(row["author"] or "(legacy)", {"posted": 0, "open": 0})
1254
+ bucket["posted"] += 1
1255
+ if row["status"] not in {"done", "withdrawn", "superseded"}:
1256
+ bucket["open"] += 1
1257
+
1258
+ degraded = [r for r in rows if r["verification"] in FAILING_VERIFICATIONS]
1259
+ return {
1260
+ "schema_version": 1,
1261
+ "ok": not degraded and not anomalies,
1262
+ "channel": str(root),
1263
+ "stale_days": stale_days,
1264
+ "generated": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
1265
+ "rounds": rows,
1266
+ "stalled": [r for r in rows if r["stalled"]],
1267
+ "anomalies": anomalies,
1268
+ "by_agent": by_agent,
1269
+ # Who may write at all. Without this the operator can see what was said
1270
+ # but not who is able to say anything -- and an agent that was onboarded
1271
+ # but never registered is silently mute rather than visibly absent.
1272
+ "registry": dict(sorted(read_registry(root).items())),
1273
+ "counts": {
1274
+ "total": len(rows),
1275
+ "legacy": sum(1 for r in rows if r["verification"] == "legacy"),
1276
+ "degraded": len(degraded),
1277
+ },
1278
+ }
1279
+
1280
+
1281
+ def render_report(data: dict) -> str:
1282
+ """Human default. Every non-`verified` row must be visible without --json."""
1283
+ lines = [
1284
+ f"Agent channel audit — {data['channel']}",
1285
+ f"generated {data['generated']} stale threshold {data['stale_days']}d",
1286
+ "",
1287
+ ]
1288
+ for row in data["rounds"]:
1289
+ number = row["round"]
1290
+ label = f"round {number}" if number is not None else "round ?"
1291
+ recipients = ", ".join(row["to"]) or "-"
1292
+ status = (row["status"] or "-").upper()
1293
+ lines.append(
1294
+ f"{label:<10} {row['author'] or '(legacy)':<16} -> {recipients:<16} "
1295
+ f"{status:<13} {row['verification'].upper():<12} {row['title']}"
1296
+ )
1297
+ if row["stalled"]:
1298
+ lines.append(f"{'':<10} ^ STALLED — {status.lower()} since {row['status_at']}, no follow-up")
1299
+ if row["reason"]:
1300
+ lines.append(f"{'':<10} \"{row['reason']}\"")
1301
+
1302
+ registry = data.get("registry") or {}
1303
+ lines += ["", f"Registered agents ({len(registry)}) — only these can write:"]
1304
+ for path, agent in registry.items():
1305
+ lines.append(f" {agent:<16} {path}")
1306
+ if not registry:
1307
+ lines.append(" (none — no agent can post; graphite channel register <repo> <name>-agent)")
1308
+
1309
+ legacy = data["counts"]["legacy"]
1310
+ if legacy:
1311
+ lines += [
1312
+ "",
1313
+ f"{legacy} legacy round(s): authorship is NOT verifiable in this repo. The "
1314
+ "relocation did not carry git history, so every one of them reads as "
1315
+ "graphite's regardless of who wrote it — operation-firewall's log is "
1316
+ "authoritative for those.",
1317
+ ]
1318
+ if data["anomalies"]:
1319
+ lines += ["", "Anomalies:"]
1320
+ lines += [
1321
+ f" round {a['round']}: {a['kind']}" + (f" ({a['detail']})" if a["detail"] else "")
1322
+ for a in data["anomalies"]
1323
+ ]
1324
+ lines += ["", "OK" if data["ok"] else "NOT OK — degraded or anomalous rows above"]
1325
+ return "\n".join(lines)