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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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)
|