sediment-cli 0.1.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.
- sediment_cli/__init__.py +22 -0
- sediment_cli/attribution.py +3881 -0
- sediment_cli/cli.py +1922 -0
- sediment_cli/client.py +323 -0
- sediment_cli/delivery.py +1334 -0
- sediment_cli/local_postgres.py +337 -0
- sediment_cli/transcript.py +1764 -0
- sediment_cli/ui.py +123 -0
- sediment_cli-0.1.0.dist-info/METADATA +16 -0
- sediment_cli-0.1.0.dist-info/RECORD +13 -0
- sediment_cli-0.1.0.dist-info/WHEEL +4 -0
- sediment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sediment_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,3881 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Sediment attribution stamper — the notes-attribution client.
|
|
3
|
+
|
|
4
|
+
Records which coding-agent sessions contributed to a commit as a git note under
|
|
5
|
+
``refs/notes/sediment``, so the server-side attribution derivation can join
|
|
6
|
+
commits to gateway/OTel sessions deterministically instead of by similarity
|
|
7
|
+
alone.
|
|
8
|
+
|
|
9
|
+
Stdlib only, no daemon. Nine subcommands:
|
|
10
|
+
|
|
11
|
+
mark --tool NAME agent-hook entry point: reads the agent's hook payload on
|
|
12
|
+
stdin, records a session marker in the repo's git dir,
|
|
13
|
+
and appends a ``mark`` line to
|
|
14
|
+
``~/.sediment/attribution.log`` (once per new marker,
|
|
15
|
+
not per tool call); if that repo has no sediment post-commit hook
|
|
16
|
+
(so the marker can never be consumed — e.g. a clone the
|
|
17
|
+
agent created itself), also logs the ``unhooked-repo``
|
|
18
|
+
miss; in owner-allowlisted repos
|
|
19
|
+
(``auto_install_remotes`` in ``config.json``) it instead
|
|
20
|
+
installs/refreshes the git hooks itself, so the repo
|
|
21
|
+
stamps from its first agent commit onward
|
|
22
|
+
cursor-hook Cursor native-hook entry point: marks Agent and Tab edits;
|
|
23
|
+
successful Agent Write calls also emit one implicit-accept
|
|
24
|
+
developer decision when telemetry is configured
|
|
25
|
+
stamp post-commit hook: writes the note on HEAD from the
|
|
26
|
+
markers, logs ``note-created``, then consumes its
|
|
27
|
+
marker generations
|
|
28
|
+
union-squash-notes MSG_FILE SOURCE
|
|
29
|
+
prepare-commit-msg hook: on a ``git merge --squash``
|
|
30
|
+
commit (each squashed branch commit already cleared its
|
|
31
|
+
own markers when it was stamped), reads the squashed
|
|
32
|
+
commits' SHAs from the still-available squash message
|
|
33
|
+
and unions their already-written notes back into the
|
|
34
|
+
local marker file, so the post-commit ``stamp`` step
|
|
35
|
+
above writes a correct note on the squash commit too
|
|
36
|
+
push-notes REMOTE pre-push hook: reconciles the notes ref with the remote
|
|
37
|
+
(fetch + ``cat_sort_uniq`` union merge), then pushes it
|
|
38
|
+
alongside the push, retrying once if the remote moved;
|
|
39
|
+
a final failure is logged to
|
|
40
|
+
``~/.sediment/attribution.log``
|
|
41
|
+
repair-notes [REMOTE]
|
|
42
|
+
operator command (not a hook, NOT best-effort): the same
|
|
43
|
+
reconcile + push on demand, for a machine whose notes
|
|
44
|
+
ref already diverged, or a fresh machine adopting
|
|
45
|
+
the remote's notes; exits non-zero on failure
|
|
46
|
+
doctor [REPO ...] operator command: one-shot health check of everything
|
|
47
|
+
attribution needs on this machine — agent hook entries,
|
|
48
|
+
the fleet git template, the attribution log's recorded
|
|
49
|
+
misses, and per REPO the git hook set, ``notes.rewriteRef``,
|
|
50
|
+
the notes ref against origin (the diverged state),
|
|
51
|
+
and markers a commit failed to consume. One line per
|
|
52
|
+
finding; exits 1 when any check FAILs. Read-only unless
|
|
53
|
+
``--fetch``, which writes only the notes tracking ref
|
|
54
|
+
install [REPO] installs agent hooks (user-level) + git hooks (per-repo)
|
|
55
|
+
+ ``notes.rewriteRef`` (local config) + the agent
|
|
56
|
+
telemetry env files generated from the ``sediment
|
|
57
|
+
login`` config (``--no-env`` skips; ``--user-id``
|
|
58
|
+
stamps per-developer attribution; ``--gateway-url``/
|
|
59
|
+
``--gateway-key`` add the completions routing);
|
|
60
|
+
``--transcripts`` opts in to the SessionEnd transcript
|
|
61
|
+
extractor and its PreToolUse snapshot hook
|
|
62
|
+
(sediment_transcript.py — ships edit text pairs, a
|
|
63
|
+
different privacy class, hence opt-in)
|
|
64
|
+
install --fleet emits the machine-wide MDM bundle (git ``init.templateDir``
|
|
65
|
+
hooks template, system-gitconfig fragment, Claude Code /
|
|
66
|
+
Codex hook fragments) to a directory; ``--apply``
|
|
67
|
+
provisions this machine directly (needs privileges)
|
|
68
|
+
uninstall [REPO] removes the per-repo git hooks; ``--agents`` also removes
|
|
69
|
+
the agent-hook entries
|
|
70
|
+
|
|
71
|
+
Privacy contract (normative — tested): the note payload contains ONLY ``tool``,
|
|
72
|
+
``session_id``, and timestamps. Local markers also carry a UUID generation.
|
|
73
|
+
No prompt text, no diff content, no file paths, no model names, no hostnames,
|
|
74
|
+
no developer identity
|
|
75
|
+
beyond what the commit object already carries.
|
|
76
|
+
|
|
77
|
+
``mark``/``stamp``/``union-squash-notes``/``push-notes`` are best-effort:
|
|
78
|
+
they always exit 0 so they can never break a tool call, a commit, or a push.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import argparse
|
|
84
|
+
import importlib.util
|
|
85
|
+
import ipaddress
|
|
86
|
+
import json
|
|
87
|
+
import os
|
|
88
|
+
import re
|
|
89
|
+
import shlex
|
|
90
|
+
import subprocess
|
|
91
|
+
import sys
|
|
92
|
+
import time
|
|
93
|
+
import tomllib
|
|
94
|
+
import urllib.error
|
|
95
|
+
import urllib.parse
|
|
96
|
+
import urllib.request
|
|
97
|
+
from collections.abc import Iterable, Iterator
|
|
98
|
+
from contextlib import contextmanager
|
|
99
|
+
from datetime import UTC, datetime
|
|
100
|
+
from itertools import islice
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
from uuid import UUID, uuid4
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
from . import ui
|
|
106
|
+
except ImportError: # pragma: no cover — run by path, not as a package
|
|
107
|
+
# The checkout shim (scripts/sediment_attribution.py) and the fleet
|
|
108
|
+
# bundle execute this file standalone, where no sibling ui module
|
|
109
|
+
# exists. Those are hook/MDM piped contexts: plain text is the correct
|
|
110
|
+
# output there, so a passthrough stand-in keeps the bytes identical.
|
|
111
|
+
class ui: # type: ignore[no-redef] # ponytail: plain-text stand-in
|
|
112
|
+
@staticmethod
|
|
113
|
+
def style(text: str, *names: str, stream=None) -> str:
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def glyph(char: str, name: str, stream=None) -> str:
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def error_line(msg: str) -> str:
|
|
122
|
+
return f"error: {msg}"
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def warn_line(msg: str) -> str:
|
|
126
|
+
return f"warning: {msg}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
NOTES_REF = "refs/notes/sediment"
|
|
130
|
+
MARKER_FILENAME = "sediment-sessions"
|
|
131
|
+
MARKER_LOCK_FILENAME = "sediment-sessions.lock"
|
|
132
|
+
NOTES_LOCK_FILENAME = "sediment-notes.lock"
|
|
133
|
+
SCHEMA_VERSION = 1
|
|
134
|
+
_DOCTOR_USER_AGENT = "sediment-doctor/1"
|
|
135
|
+
# Recursion guard: push-notes runs `git push`, which fires pre-push again.
|
|
136
|
+
PUSH_GUARD_ENV = "SEDIMENT_NOTES_PUSH_IN_PROGRESS"
|
|
137
|
+
HOOK_BLOCK_BEGIN = "# >>> sediment-attribution >>>"
|
|
138
|
+
HOOK_BLOCK_END = "# <<< sediment-attribution <<<"
|
|
139
|
+
# Group 1 is the block body; the trailing newline is consumed so a rewrite
|
|
140
|
+
# leaves the surrounding hook byte-identical.
|
|
141
|
+
_HOOK_BLOCK_RE = re.compile(
|
|
142
|
+
re.escape(HOOK_BLOCK_BEGIN) + r"(.*?)" + re.escape(HOOK_BLOCK_END) + r"\n?",
|
|
143
|
+
re.DOTALL,
|
|
144
|
+
)
|
|
145
|
+
# The three git hooks and the subcommand each one invokes. One table, so the
|
|
146
|
+
# per-repo installer, the fleet template, doctor and uninstall can never
|
|
147
|
+
# disagree about which hooks exist.
|
|
148
|
+
_REPO_HOOKS = (
|
|
149
|
+
("post-commit", "stamp"),
|
|
150
|
+
("prepare-commit-msg", 'union-squash-notes "$1" "$2"'),
|
|
151
|
+
("pre-push", 'push-notes "$1"'),
|
|
152
|
+
)
|
|
153
|
+
# Substrings used to recognise our entries in agent hook configs. The
|
|
154
|
+
# transcript extractor is installed and uninstalled by this installer too,
|
|
155
|
+
# but only behind the opt-in
|
|
156
|
+
# ``install --transcripts`` flag: it ships edit text pairs off the machine —
|
|
157
|
+
# a different privacy class than the stamper's own session-id-only hooks.
|
|
158
|
+
# The hook clients' invocation forms: the legacy/fleet script names, the
|
|
159
|
+
# packaged modules, and the installed `sediment`
|
|
160
|
+
# executable. Recognition must accept every generation — install heals old
|
|
161
|
+
# entries in place, and doctor must see an old install as ours, not absent.
|
|
162
|
+
_HOOK_COMMAND_TAGS = (
|
|
163
|
+
"sediment_attribution.py",
|
|
164
|
+
"attribution.py",
|
|
165
|
+
"sediment_transcript.py",
|
|
166
|
+
"sediment_cli/transcript.py",
|
|
167
|
+
)
|
|
168
|
+
_SEDIMENT_EXE_RE = re.compile(
|
|
169
|
+
r'^"[^"]*/sediment(?:\.exe)?" (?:cursor-hook|mark|transcript)(?:\s|$)',
|
|
170
|
+
re.IGNORECASE,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_sediment_command(command: str) -> bool:
|
|
175
|
+
normalized = command.replace("\\", "/")
|
|
176
|
+
return any(tag in normalized for tag in _HOOK_COMMAND_TAGS) or bool(
|
|
177
|
+
_SEDIMENT_EXE_RE.match(normalized)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Claude Code tools whose use means "this session touched the repo" (edits and
|
|
182
|
+
# shell — shell covers agent-run `git commit`). MultiEdit is gone in 2.x
|
|
183
|
+
# clients but 1.x dispatches matchers by exact token, so keep it for them.
|
|
184
|
+
CLAUDE_MATCHER = "Edit|MultiEdit|Write|NotebookEdit|Bash"
|
|
185
|
+
|
|
186
|
+
# Cursor's native hooks use a flat command list, not the nested Claude Code
|
|
187
|
+
# and Codex hook blocks. A matcher is valid on the Agent tool events only.
|
|
188
|
+
_CURSOR_HOOKS: dict[str, str | None] = {
|
|
189
|
+
"postToolUse": "Write",
|
|
190
|
+
"postToolUseFailure": "Write",
|
|
191
|
+
"afterTabFileEdit": None,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _git(args: list[str], cwd: str | Path | None = None) -> str | None:
|
|
196
|
+
"""Run git, returning stripped stdout or None on any failure."""
|
|
197
|
+
try:
|
|
198
|
+
out = subprocess.run(
|
|
199
|
+
["git", *args],
|
|
200
|
+
cwd=cwd,
|
|
201
|
+
capture_output=True,
|
|
202
|
+
text=True,
|
|
203
|
+
timeout=30,
|
|
204
|
+
)
|
|
205
|
+
except Exception:
|
|
206
|
+
return None
|
|
207
|
+
if out.returncode != 0:
|
|
208
|
+
return None
|
|
209
|
+
return out.stdout.strip()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _git_dir(cwd: str | Path) -> Path | None:
|
|
213
|
+
"""The repo's absolute git dir for ``cwd``, or None outside a work tree."""
|
|
214
|
+
# --absolute-git-dir also succeeds in bare repos; require a work tree so
|
|
215
|
+
# hooks running in odd contexts (bare mirrors) no-op.
|
|
216
|
+
inside = _git(["rev-parse", "--is-inside-work-tree"], cwd)
|
|
217
|
+
if inside != "true":
|
|
218
|
+
return None
|
|
219
|
+
git_dir = _git(["rev-parse", "--absolute-git-dir"], cwd)
|
|
220
|
+
return Path(git_dir) if git_dir else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _now_iso() -> str:
|
|
224
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _error(message: str) -> None:
|
|
228
|
+
print(ui.error_line(message), file=sys.stderr)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _warn(message: str) -> None:
|
|
232
|
+
print(ui.warn_line(message), file=sys.stderr)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _skipped(message: str) -> str:
|
|
236
|
+
"""Report a step skipped because what it wires is absent, and return the
|
|
237
|
+
installer's status word."""
|
|
238
|
+
print(ui.style(message, "dim", stream=sys.stderr), file=sys.stderr)
|
|
239
|
+
return "skipped"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── attribution log ───────────────────────────────────────────────────────
|
|
243
|
+
#
|
|
244
|
+
# Local, greppable record of both the chain's healthy path (`mark`,
|
|
245
|
+
# `note-created`) and the misses that are otherwise invisible: a best-effort
|
|
246
|
+
# hook prints at most one stderr line, buried in git output — an unhooked
|
|
247
|
+
# repo an agent is editing, a notes push that cannot land. A `mark` with no
|
|
248
|
+
# following `note-created` for the same git_dir/session_id is a broken
|
|
249
|
+
# chain; doctor's `_DOCTOR_LOG_EVENTS` still names only the misses,
|
|
250
|
+
# so the breadcrumbs stay invisible to its summary and are meant to be
|
|
251
|
+
# grepped directly. Local file only; never shipped — the marker/note
|
|
252
|
+
# privacy contract is unchanged.
|
|
253
|
+
|
|
254
|
+
ATTRIBUTION_LOG_ENV = "SEDIMENT_ATTRIBUTION_LOG"
|
|
255
|
+
ATTRIBUTION_LOG_CAP_BYTES = 256 * 1024
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _log_path() -> Path:
|
|
259
|
+
override = os.environ.get(ATTRIBUTION_LOG_ENV)
|
|
260
|
+
if override:
|
|
261
|
+
return Path(override)
|
|
262
|
+
return Path.home() / ".sediment" / "attribution.log"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _log_event(event: str, **fields: str) -> None:
|
|
266
|
+
"""Append one greppable JSON line to the local attribution log."""
|
|
267
|
+
path = _log_path()
|
|
268
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
try:
|
|
270
|
+
# ponytail: single-slot rotation, .1 overwritten each time
|
|
271
|
+
if path.stat().st_size > ATTRIBUTION_LOG_CAP_BYTES:
|
|
272
|
+
path.replace(path.with_name(path.name + ".1"))
|
|
273
|
+
except OSError:
|
|
274
|
+
pass
|
|
275
|
+
line = json.dumps({"at": _now_iso(), "event": event, **fields})
|
|
276
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
277
|
+
fh.write(line + "\n")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _post_commit_installed(hooks_dir: Path | None) -> bool:
|
|
281
|
+
"""True when the repo's effective post-commit hook carries our block.
|
|
282
|
+
An unresolvable hooks dir counts as installed — stay quiet rather than
|
|
283
|
+
cry wolf."""
|
|
284
|
+
if hooks_dir is None:
|
|
285
|
+
return True
|
|
286
|
+
try:
|
|
287
|
+
text = (hooks_dir / "post-commit").read_text(encoding="utf-8")
|
|
288
|
+
except OSError:
|
|
289
|
+
return False
|
|
290
|
+
return HOOK_BLOCK_BEGIN in text
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── auto-install (owner allowlist) ────────────────────────────────────────
|
|
294
|
+
#
|
|
295
|
+
# A platform owner can list remote prefixes whose repos `mark` may converge:
|
|
296
|
+
# on each new marker it (re-)runs the idempotent per-repo git-hook install,
|
|
297
|
+
# so a clone the agent created five seconds ago — or a checkout whose hooks
|
|
298
|
+
# predate a newer entry — is hooked before its first commit. Scoped to an
|
|
299
|
+
# explicit allowlist because installing everywhere would make `pre-push`
|
|
300
|
+
# push a notes ref (session UUIDs) to third-party remotes the org does not
|
|
301
|
+
# own. No config, or an empty list, means the feature is off.
|
|
302
|
+
|
|
303
|
+
CONFIG_ENV = "SEDIMENT_ATTRIBUTION_CONFIG"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _config_paths() -> list[Path]:
|
|
307
|
+
override = os.environ.get(CONFIG_ENV)
|
|
308
|
+
if override:
|
|
309
|
+
return [Path(override)]
|
|
310
|
+
return [
|
|
311
|
+
# Fleet: MDM ships config.json next to the script (e.g. /opt/sediment).
|
|
312
|
+
Path(__file__).resolve().parent / "config.json",
|
|
313
|
+
# Per-user, no privileges needed.
|
|
314
|
+
Path.home() / ".sediment" / "config.json",
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _load_auto_install_remotes() -> list[str] | None:
|
|
319
|
+
"""The owner's auto-install remote prefixes; None or [] means off.
|
|
320
|
+
|
|
321
|
+
First config file found wins — a fleet config deliberately shadows a
|
|
322
|
+
per-user one. A config that exists but does not parse, or carries the
|
|
323
|
+
wrong shape, logs a ``config-error`` event and counts as off: a typo
|
|
324
|
+
must not silently widen or narrow which repos get stamped.
|
|
325
|
+
"""
|
|
326
|
+
for path in _config_paths():
|
|
327
|
+
try:
|
|
328
|
+
raw = path.read_text(encoding="utf-8")
|
|
329
|
+
except OSError:
|
|
330
|
+
continue # not present — try the next location
|
|
331
|
+
try:
|
|
332
|
+
cfg = json.loads(raw)
|
|
333
|
+
if not isinstance(cfg, dict):
|
|
334
|
+
raise ValueError("config must be a JSON object")
|
|
335
|
+
remotes = cfg.get("auto_install_remotes", [])
|
|
336
|
+
# Reject blank entries outright: "" normalizes to "/" and would
|
|
337
|
+
# silently allowlist every local-path remote.
|
|
338
|
+
if not (
|
|
339
|
+
isinstance(remotes, list)
|
|
340
|
+
and all(isinstance(r, str) and r.strip() for r in remotes)
|
|
341
|
+
):
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"auto_install_remotes must be a list of non-empty strings"
|
|
344
|
+
)
|
|
345
|
+
except ValueError as exc:
|
|
346
|
+
_log_event("config-error", path=str(path), detail=str(exc)[:200])
|
|
347
|
+
return None
|
|
348
|
+
return remotes
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _normalize_remote(url: str) -> str:
|
|
353
|
+
"""Lower-cased ``host/path/`` with protocol, credentials, and ``.git``
|
|
354
|
+
stripped, so one allowlist prefix covers https, ssh, and scp-like forms
|
|
355
|
+
(``github.com/acme/`` matches ``https://github.com/acme/repo.git`` and
|
|
356
|
+
``git@github.com:acme/repo.git`` alike). The trailing slash makes the
|
|
357
|
+
prefix match segment-aligned: ``.../repo/`` never matches ``.../repo2``.
|
|
358
|
+
"""
|
|
359
|
+
url = url.strip().lower()
|
|
360
|
+
for prefix in ("ssh://", "git://", "http://", "https://"):
|
|
361
|
+
if url.startswith(prefix):
|
|
362
|
+
url = url[len(prefix) :]
|
|
363
|
+
break
|
|
364
|
+
else:
|
|
365
|
+
# scp-like: git@github.com:acme/repo → git@github.com/acme/repo
|
|
366
|
+
if "@" in url.split("/", 1)[0] and ":" in url:
|
|
367
|
+
url = url.replace(":", "/", 1)
|
|
368
|
+
host, _, rest = url.partition("/")
|
|
369
|
+
host = host.rpartition("@")[2]
|
|
370
|
+
url = f"{host}/{rest}"
|
|
371
|
+
if url.endswith(".git"):
|
|
372
|
+
url = url[: -len(".git")]
|
|
373
|
+
return url.rstrip("/") + "/"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _remote_allowlisted(repo: str | Path, remotes: list[str]) -> bool:
|
|
377
|
+
"""True when the repo's origin URL falls under an allowlisted prefix."""
|
|
378
|
+
url = _git(["remote", "get-url", "origin"], repo)
|
|
379
|
+
if not url:
|
|
380
|
+
return False # no origin — nothing to match against
|
|
381
|
+
normalized = _normalize_remote(url)
|
|
382
|
+
return any(normalized.startswith(_normalize_remote(r)) for r in remotes)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _install_repo_hooks(hooks_dir: Path, repo_path: Path) -> list[str]:
|
|
386
|
+
"""Write our block into the three git hooks and set notes.rewriteRef.
|
|
387
|
+
|
|
388
|
+
Idempotent — shared by ``install`` and mark's allowlisted auto-install.
|
|
389
|
+
Returns the hook names whose block landed (an existing non-sh hook is
|
|
390
|
+
left alone with a stderr warning from ``_install_hook_block``).
|
|
391
|
+
"""
|
|
392
|
+
installed = [
|
|
393
|
+
name
|
|
394
|
+
for name, sub in _REPO_HOOKS
|
|
395
|
+
if _install_hook_block(hooks_dir / name, _script_invocation(sub))
|
|
396
|
+
]
|
|
397
|
+
# notes.rewriteRef is multi-valued: --add alongside any pre-existing value
|
|
398
|
+
# instead of overwriting it.
|
|
399
|
+
existing = _git(["config", "--get-all", "notes.rewriteRef"], repo_path) or ""
|
|
400
|
+
if NOTES_REF not in existing.splitlines():
|
|
401
|
+
_git(["config", "--add", "notes.rewriteRef", NOTES_REF], repo_path)
|
|
402
|
+
return installed
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _record_marker(tool: str, session_id: str, cwd: str) -> None:
|
|
406
|
+
"""Record one validated Session marker when ``cwd`` is a git worktree."""
|
|
407
|
+
git_dir = _git_dir(cwd)
|
|
408
|
+
if git_dir is None:
|
|
409
|
+
return
|
|
410
|
+
marker_path = git_dir / MARKER_FILENAME
|
|
411
|
+
try:
|
|
412
|
+
with _file_lock(git_dir / MARKER_LOCK_FILENAME, "marker_busy", timeout=1):
|
|
413
|
+
entries = _marker_entries(marker_path)
|
|
414
|
+
key = (tool, session_id)
|
|
415
|
+
fresh = key not in entries
|
|
416
|
+
if fresh:
|
|
417
|
+
entries[key] = {
|
|
418
|
+
"tool": tool,
|
|
419
|
+
"session_id": session_id,
|
|
420
|
+
"stamped_at": _now_iso(),
|
|
421
|
+
}
|
|
422
|
+
entries[key]["generation"] = str(uuid4())
|
|
423
|
+
_write_markers(marker_path, list(entries.values()))
|
|
424
|
+
except _CaptureFailure as exc:
|
|
425
|
+
_capture_failure(exc.reason, git_dir)
|
|
426
|
+
return
|
|
427
|
+
except OSError:
|
|
428
|
+
_capture_failure("marker_write_failed", git_dir)
|
|
429
|
+
return
|
|
430
|
+
if not fresh:
|
|
431
|
+
return # Preserve the first active timestamp and breadcrumb.
|
|
432
|
+
# A breadcrumb for the healthy path, not just the misses: a
|
|
433
|
+
# session whose mark never gets followed by a stamp's note-created
|
|
434
|
+
# narrows a broken chain to one greppable diff instead of a
|
|
435
|
+
# cross-machine differential. Logged once per new marker, same as
|
|
436
|
+
# the first insertion — refreshing a generation skips repeats.
|
|
437
|
+
_log_event("mark", git_dir=str(git_dir), tool=tool, session_id=session_id)
|
|
438
|
+
# Resolved once: `mark` runs on every agent tool call and
|
|
439
|
+
# _hooks_dir shells out to git.
|
|
440
|
+
hooks_dir = _hooks_dir(Path(cwd))
|
|
441
|
+
hooked = _post_commit_installed(hooks_dir)
|
|
442
|
+
remotes = _load_auto_install_remotes()
|
|
443
|
+
if remotes and hooks_dir is not None and _remote_allowlisted(cwd, remotes):
|
|
444
|
+
# Owner-allowlisted repo: converge the git hooks right here, so
|
|
445
|
+
# the repo is stamped from its first agent commit onward.
|
|
446
|
+
# Runs once per new marker (per session per repo) and also heals
|
|
447
|
+
# partial/stale hook sets — the install is idempotent.
|
|
448
|
+
_install_repo_hooks(hooks_dir, Path(cwd))
|
|
449
|
+
if not hooked:
|
|
450
|
+
_log_event(
|
|
451
|
+
"auto-installed",
|
|
452
|
+
git_dir=str(git_dir),
|
|
453
|
+
tool=tool,
|
|
454
|
+
session_id=session_id,
|
|
455
|
+
)
|
|
456
|
+
elif not hooked:
|
|
457
|
+
_log_event(
|
|
458
|
+
"unhooked-repo",
|
|
459
|
+
git_dir=str(git_dir),
|
|
460
|
+
tool=tool,
|
|
461
|
+
session_id=session_id,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def cmd_mark(tool: str) -> int:
|
|
466
|
+
"""Record {tool, session_id} for the repo the agent is editing.
|
|
467
|
+
|
|
468
|
+
Reads the agent's hook payload (JSON) on stdin. Claude Code and Codex both
|
|
469
|
+
carry ``session_id`` and ``cwd`` (Codex may use ``thread_id``).
|
|
470
|
+
Exits 0 on every path; a marker miss
|
|
471
|
+
only means notes attribution degrades to the jaccard fallback for that
|
|
472
|
+
commit.
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
476
|
+
if not isinstance(payload, dict):
|
|
477
|
+
return 0
|
|
478
|
+
session_id = payload.get("session_id") or payload.get("thread_id")
|
|
479
|
+
if not isinstance(session_id, str) or not session_id:
|
|
480
|
+
return 0
|
|
481
|
+
cwd = payload.get("cwd")
|
|
482
|
+
if not isinstance(cwd, str) or not cwd:
|
|
483
|
+
cwd = os.getcwd()
|
|
484
|
+
_record_marker(tool, session_id, cwd)
|
|
485
|
+
except Exception:
|
|
486
|
+
pass # best-effort: never fail the agent's tool call
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _cursor_trail(message: str) -> None:
|
|
491
|
+
"""Write one bounded local Cursor capture diagnostic."""
|
|
492
|
+
print(f"sediment-cursor-hook: {message}", file=sys.stderr)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _cursor_repository_directory(payload: dict, event: str) -> str | None:
|
|
496
|
+
"""Resolve observed paths without using the hook process's working directory."""
|
|
497
|
+
try:
|
|
498
|
+
inputs = (
|
|
499
|
+
payload if event == "afterTabFileEdit" else payload.get("tool_input", {})
|
|
500
|
+
)
|
|
501
|
+
key = "file_path" if event == "afterTabFileEdit" else "path"
|
|
502
|
+
if not isinstance(inputs, dict):
|
|
503
|
+
raise ValueError
|
|
504
|
+
edited = None
|
|
505
|
+
if key in inputs:
|
|
506
|
+
value = inputs[key]
|
|
507
|
+
if not isinstance(value, str) or not value or "\0" in value:
|
|
508
|
+
raise ValueError
|
|
509
|
+
edited = Path(value)
|
|
510
|
+
|
|
511
|
+
if edited is not None and edited.is_absolute():
|
|
512
|
+
# The edit identifies a nested repository even when cwd names its parent.
|
|
513
|
+
paths = [edited.resolve()]
|
|
514
|
+
else:
|
|
515
|
+
cwd = payload.get("cwd")
|
|
516
|
+
roots = (
|
|
517
|
+
[cwd] if cwd not in (None, "") else payload.get("workspace_roots", [])
|
|
518
|
+
)
|
|
519
|
+
if not isinstance(roots, list):
|
|
520
|
+
raise ValueError
|
|
521
|
+
if not roots:
|
|
522
|
+
_cursor_trail("no repository directory; Attribution mark skipped")
|
|
523
|
+
return None
|
|
524
|
+
paths = []
|
|
525
|
+
for root in roots:
|
|
526
|
+
if not isinstance(root, str) or not root or "\0" in root:
|
|
527
|
+
raise ValueError
|
|
528
|
+
directory = Path(root)
|
|
529
|
+
if not directory.is_absolute() or not directory.is_dir():
|
|
530
|
+
raise ValueError
|
|
531
|
+
paths.append((directory / edited if edited else directory).resolve())
|
|
532
|
+
|
|
533
|
+
repositories: dict[Path, str] = {}
|
|
534
|
+
for path in sorted(set(paths)):
|
|
535
|
+
if edited is not None and path.is_dir():
|
|
536
|
+
raise ValueError
|
|
537
|
+
directory = path.parent if edited is not None else path
|
|
538
|
+
git_dir = _git_dir(directory)
|
|
539
|
+
if git_dir is None:
|
|
540
|
+
raise ValueError
|
|
541
|
+
repositories[git_dir] = str(directory)
|
|
542
|
+
if len(repositories) != 1:
|
|
543
|
+
_cursor_trail("ambiguous repository directory; Attribution mark skipped")
|
|
544
|
+
return None
|
|
545
|
+
return next(iter(repositories.values()))
|
|
546
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
547
|
+
_cursor_trail("invalid repository directory; Attribution mark skipped")
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _capture_client_path(name: str) -> Path:
|
|
552
|
+
for filename in (f"{name}.py", f"sediment_{name}.py"):
|
|
553
|
+
path = Path(__file__).with_name(filename)
|
|
554
|
+
if path.is_file():
|
|
555
|
+
return path
|
|
556
|
+
raise RuntimeError("capture helper is unavailable")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _capture_client(name: str):
|
|
560
|
+
"""Load the same stdlib owner from the package or a complete fleet copy."""
|
|
561
|
+
if __package__:
|
|
562
|
+
return importlib.import_module(f".{name}", __package__)
|
|
563
|
+
path = _capture_client_path(name)
|
|
564
|
+
module_name = f"_sediment_enrollment_{name}"
|
|
565
|
+
existing = sys.modules.get(module_name)
|
|
566
|
+
if existing is not None and getattr(existing, "__file__", None) == str(path):
|
|
567
|
+
return existing
|
|
568
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
569
|
+
if spec is None or spec.loader is None:
|
|
570
|
+
raise RuntimeError("capture helper is unavailable")
|
|
571
|
+
module = importlib.util.module_from_spec(spec)
|
|
572
|
+
# Dataclasses resolve postponed annotations through the module registry.
|
|
573
|
+
sys.modules[module_name] = module
|
|
574
|
+
try:
|
|
575
|
+
spec.loader.exec_module(module)
|
|
576
|
+
except Exception:
|
|
577
|
+
sys.modules.pop(module_name, None)
|
|
578
|
+
raise
|
|
579
|
+
return module
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _transcript_client():
|
|
583
|
+
return _capture_client("transcript")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _cursor_attribute(key: str, value: str | bool) -> dict:
|
|
587
|
+
encoded = (
|
|
588
|
+
{"boolValue": value} if isinstance(value, bool) else {"stringValue": value}
|
|
589
|
+
)
|
|
590
|
+
return {"key": key, "value": encoded}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _cursor_decision_payload(
|
|
594
|
+
session_id: str, call_id: str, occurred_at_ns: int, user_id: str | None
|
|
595
|
+
) -> dict:
|
|
596
|
+
attributes = [
|
|
597
|
+
_cursor_attribute("agent", "cursor"),
|
|
598
|
+
_cursor_attribute("session.id", session_id),
|
|
599
|
+
_cursor_attribute("tool_use_id", call_id),
|
|
600
|
+
_cursor_attribute("tool_name", "Write"),
|
|
601
|
+
_cursor_attribute("decision", "accept"),
|
|
602
|
+
_cursor_attribute("explicit", False),
|
|
603
|
+
]
|
|
604
|
+
resource = (
|
|
605
|
+
{"attributes": [_cursor_attribute("user.id", user_id)]} if user_id else {}
|
|
606
|
+
)
|
|
607
|
+
return {
|
|
608
|
+
"resourceLogs": [
|
|
609
|
+
{
|
|
610
|
+
"resource": resource,
|
|
611
|
+
"scopeLogs": [
|
|
612
|
+
{
|
|
613
|
+
"logRecords": [
|
|
614
|
+
{
|
|
615
|
+
"body": {"stringValue": "sediment.tool_decision"},
|
|
616
|
+
"timeUnixNano": str(occurred_at_ns),
|
|
617
|
+
"attributes": attributes,
|
|
618
|
+
}
|
|
619
|
+
]
|
|
620
|
+
}
|
|
621
|
+
],
|
|
622
|
+
}
|
|
623
|
+
]
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def cmd_cursor_hook() -> int:
|
|
628
|
+
"""Translate one native Cursor hook payload without blocking Cursor."""
|
|
629
|
+
occurred_at_ns = time.time_ns()
|
|
630
|
+
try:
|
|
631
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
632
|
+
except (OSError, ValueError):
|
|
633
|
+
_cursor_trail("invalid JSON; skipping")
|
|
634
|
+
return 0
|
|
635
|
+
if not isinstance(payload, dict):
|
|
636
|
+
_cursor_trail("input is not a JSON object; skipping")
|
|
637
|
+
return 0
|
|
638
|
+
|
|
639
|
+
session_id = payload.get("conversation_id")
|
|
640
|
+
if not isinstance(session_id, str) or not session_id:
|
|
641
|
+
_cursor_trail("missing conversation_id; skipping")
|
|
642
|
+
return 0
|
|
643
|
+
event = payload.get("hook_event_name")
|
|
644
|
+
if not isinstance(event, str) or event not in _CURSOR_HOOKS:
|
|
645
|
+
_cursor_trail("unsupported hook_event_name; skipping")
|
|
646
|
+
return 0
|
|
647
|
+
if event != "afterTabFileEdit" and payload.get("tool_name") != "Write":
|
|
648
|
+
_cursor_trail("non-Write tool event; skipping")
|
|
649
|
+
return 0
|
|
650
|
+
|
|
651
|
+
cwd = _cursor_repository_directory(payload, event)
|
|
652
|
+
if cwd is not None:
|
|
653
|
+
try:
|
|
654
|
+
_record_marker("cursor", session_id, cwd)
|
|
655
|
+
except Exception:
|
|
656
|
+
_cursor_trail("Attribution marking failed; decision capture continues")
|
|
657
|
+
|
|
658
|
+
if event != "postToolUse":
|
|
659
|
+
return 0
|
|
660
|
+
call_id = payload.get("tool_use_id")
|
|
661
|
+
if not isinstance(call_id, str) or not call_id:
|
|
662
|
+
_cursor_trail("missing tool_use_id; decision skipped")
|
|
663
|
+
return 0
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
telemetry = _transcript_client()
|
|
667
|
+
endpoint = telemetry._endpoint()
|
|
668
|
+
if endpoint is None:
|
|
669
|
+
return 0
|
|
670
|
+
decision = _cursor_decision_payload(
|
|
671
|
+
session_id,
|
|
672
|
+
call_id,
|
|
673
|
+
occurred_at_ns,
|
|
674
|
+
telemetry._resource_user_id(),
|
|
675
|
+
)
|
|
676
|
+
telemetry._post(endpoint, decision)
|
|
677
|
+
except Exception:
|
|
678
|
+
_cursor_trail("decision delivery failed; skipping")
|
|
679
|
+
return 0
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class _CaptureFailure(Exception):
|
|
683
|
+
"""A content-free local capture outcome; never include exception text."""
|
|
684
|
+
|
|
685
|
+
def __init__(self, reason: str):
|
|
686
|
+
self.reason = reason
|
|
687
|
+
super().__init__(reason)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
_CAPTURE_FAILURE_REASONS = (
|
|
691
|
+
"marker_busy",
|
|
692
|
+
"marker_read_failed",
|
|
693
|
+
"marker_write_failed",
|
|
694
|
+
"marker_durability_unconfirmed",
|
|
695
|
+
"stamp_busy",
|
|
696
|
+
"stamp_target_failed",
|
|
697
|
+
"stamp_log_failed",
|
|
698
|
+
"note_read_failed",
|
|
699
|
+
"note_invalid",
|
|
700
|
+
"note_write_failed",
|
|
701
|
+
"note_timeout",
|
|
702
|
+
"stamp_cleanup_failed",
|
|
703
|
+
"stamp_cleanup_unconfirmed",
|
|
704
|
+
"notes_reconcile_busy",
|
|
705
|
+
"notes_reconcile_failed",
|
|
706
|
+
"notes_lock_failed",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _capture_failure(reason: str, git_dir: Path | None, sha: str = "") -> None:
|
|
711
|
+
try:
|
|
712
|
+
_log_event(reason, git_dir=str(git_dir or ""), sha=sha)
|
|
713
|
+
except OSError:
|
|
714
|
+
pass # The stderr diagnostic still reports an unwritable local log.
|
|
715
|
+
target = f" sha={sha}" if sha else ""
|
|
716
|
+
print(f"sediment-attribution: {reason}{target}", file=sys.stderr)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@contextmanager
|
|
720
|
+
def _file_lock(path: Path, busy: str, *, timeout: float = 0) -> Iterator[None]:
|
|
721
|
+
"""Lock a stable inode. Marker waits are bounded; notes never wait."""
|
|
722
|
+
try:
|
|
723
|
+
import fcntl
|
|
724
|
+
except ImportError:
|
|
725
|
+
raise OSError("capture requires POSIX file locks") from None
|
|
726
|
+
|
|
727
|
+
with path.open("a+b") as handle:
|
|
728
|
+
deadline = time.monotonic() + timeout
|
|
729
|
+
while True:
|
|
730
|
+
try:
|
|
731
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
732
|
+
break
|
|
733
|
+
except BlockingIOError:
|
|
734
|
+
remaining = deadline - time.monotonic()
|
|
735
|
+
if remaining <= 0:
|
|
736
|
+
raise _CaptureFailure(busy) from None
|
|
737
|
+
time.sleep(min(0.01, remaining))
|
|
738
|
+
try:
|
|
739
|
+
yield
|
|
740
|
+
finally:
|
|
741
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _notes_lock_path(cwd: str | Path) -> Path:
|
|
745
|
+
# Relative to cwd in the main work tree, absolute in a linked one;
|
|
746
|
+
# --path-format=absolute would pin git >= 2.31 for nothing.
|
|
747
|
+
common = _git(["rev-parse", "--git-common-dir"], cwd)
|
|
748
|
+
if common is None:
|
|
749
|
+
raise _CaptureFailure("notes_lock_failed")
|
|
750
|
+
return Path(cwd, common) / NOTES_LOCK_FILENAME
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _sync_directory(directory: Path) -> None:
|
|
754
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
755
|
+
try:
|
|
756
|
+
os.fsync(fd)
|
|
757
|
+
finally:
|
|
758
|
+
os.close(fd)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _write_markers(marker_path: Path, entries: list[dict]) -> None:
|
|
762
|
+
"""Publish one complete population while the caller holds its marker lock."""
|
|
763
|
+
# Bounded cleanup of interrupted writers' private files, under the same lock.
|
|
764
|
+
for path in islice(marker_path.parent.iterdir(), 128):
|
|
765
|
+
if re.fullmatch(r"\.sediment-sessions\.[0-9a-f]{32}\.tmp", path.name):
|
|
766
|
+
path.unlink(missing_ok=True)
|
|
767
|
+
if not entries:
|
|
768
|
+
marker_path.unlink(missing_ok=True)
|
|
769
|
+
else:
|
|
770
|
+
temporary = marker_path.with_name(f".sediment-sessions.{uuid4().hex}.tmp")
|
|
771
|
+
try:
|
|
772
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
773
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
774
|
+
for entry in entries:
|
|
775
|
+
handle.write(json.dumps(entry) + "\n")
|
|
776
|
+
handle.flush()
|
|
777
|
+
os.fsync(handle.fileno())
|
|
778
|
+
os.replace(temporary, marker_path)
|
|
779
|
+
finally:
|
|
780
|
+
temporary.unlink(missing_ok=True)
|
|
781
|
+
try:
|
|
782
|
+
_sync_directory(marker_path.parent)
|
|
783
|
+
except OSError:
|
|
784
|
+
raise _CaptureFailure("marker_durability_unconfirmed") from None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _read_markers(marker_path: Path, *, strict: bool = False) -> list[dict]:
|
|
788
|
+
"""The rows that parse. A malformed row (a legacy append interrupted
|
|
789
|
+
mid-write) is skipped and counted, never a reason to refuse the file: the
|
|
790
|
+
next writer replaces the whole population under the lock and heals it.
|
|
791
|
+
``strict`` refuses only an unreadable file, where a rewrite would drop rows
|
|
792
|
+
the reader never saw."""
|
|
793
|
+
markers: list[dict] = []
|
|
794
|
+
try:
|
|
795
|
+
raw = marker_path.read_bytes()
|
|
796
|
+
except FileNotFoundError:
|
|
797
|
+
return markers
|
|
798
|
+
except OSError:
|
|
799
|
+
if strict:
|
|
800
|
+
raise _CaptureFailure("marker_read_failed") from None
|
|
801
|
+
return markers
|
|
802
|
+
skipped = 0
|
|
803
|
+
for row in raw.splitlines():
|
|
804
|
+
try:
|
|
805
|
+
# Replacement decoding would invent tool or Session identities.
|
|
806
|
+
line = row.decode("utf-8").strip()
|
|
807
|
+
if not line:
|
|
808
|
+
continue
|
|
809
|
+
entry = json.loads(line)
|
|
810
|
+
except ValueError:
|
|
811
|
+
skipped += 1
|
|
812
|
+
continue
|
|
813
|
+
if (
|
|
814
|
+
isinstance(entry, dict)
|
|
815
|
+
and isinstance(entry.get("tool"), str)
|
|
816
|
+
and isinstance(entry.get("session_id"), str)
|
|
817
|
+
):
|
|
818
|
+
markers.append(entry)
|
|
819
|
+
else:
|
|
820
|
+
skipped += 1
|
|
821
|
+
if skipped and strict:
|
|
822
|
+
try:
|
|
823
|
+
_log_event(
|
|
824
|
+
"marker_rows_skipped",
|
|
825
|
+
git_dir=str(marker_path.parent),
|
|
826
|
+
count=str(skipped),
|
|
827
|
+
)
|
|
828
|
+
except OSError:
|
|
829
|
+
pass
|
|
830
|
+
return markers
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _marker_entries(marker_path: Path) -> dict[tuple[str, str], dict]:
|
|
834
|
+
"""Normalize legacy rows under the marker lock; preserve first timestamps."""
|
|
835
|
+
entries: dict[tuple[str, str], dict] = {}
|
|
836
|
+
for marker in _read_markers(marker_path, strict=True):
|
|
837
|
+
key = (marker["tool"], marker["session_id"])
|
|
838
|
+
if key in entries:
|
|
839
|
+
continue
|
|
840
|
+
entry = {
|
|
841
|
+
name: marker[name]
|
|
842
|
+
for name in ("tool", "session_id", "stamped_at")
|
|
843
|
+
if name in marker
|
|
844
|
+
}
|
|
845
|
+
generation = marker.get("generation")
|
|
846
|
+
try:
|
|
847
|
+
UUID(generation)
|
|
848
|
+
except (ValueError, TypeError, AttributeError):
|
|
849
|
+
generation = str(uuid4())
|
|
850
|
+
entry["generation"] = generation
|
|
851
|
+
entries[key] = entry
|
|
852
|
+
return entries
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def cmd_stamp() -> int:
|
|
856
|
+
"""post-commit: write the attribution note on HEAD from the markers.
|
|
857
|
+
|
|
858
|
+
Keep Git outside the short marker lock. A successful note consumes only
|
|
859
|
+
its snapshot generations; later marks survive even for the same Session.
|
|
860
|
+
The common notes mutex serializes local writers across linked worktrees.
|
|
861
|
+
"""
|
|
862
|
+
git_dir = None
|
|
863
|
+
sha = ""
|
|
864
|
+
stage = "stamp_target_failed"
|
|
865
|
+
try:
|
|
866
|
+
cwd = os.getcwd()
|
|
867
|
+
git_dir = _git_dir(cwd)
|
|
868
|
+
if git_dir is None:
|
|
869
|
+
return 0
|
|
870
|
+
marker_path = git_dir / MARKER_FILENAME
|
|
871
|
+
if not marker_path.exists():
|
|
872
|
+
return 0
|
|
873
|
+
sha = _git(["rev-parse", "--verify", "HEAD^{commit}"], cwd) or ""
|
|
874
|
+
if not sha:
|
|
875
|
+
raise _CaptureFailure("stamp_target_failed")
|
|
876
|
+
stage = "notes_lock_failed"
|
|
877
|
+
with _file_lock(_notes_lock_path(cwd), "stamp_busy"):
|
|
878
|
+
stage = "marker_write_failed"
|
|
879
|
+
with _file_lock(git_dir / MARKER_LOCK_FILENAME, "marker_busy", timeout=1):
|
|
880
|
+
snapshot = _marker_entries(marker_path)
|
|
881
|
+
if not snapshot:
|
|
882
|
+
return 0
|
|
883
|
+
normalized = list(snapshot.values())
|
|
884
|
+
if normalized != _read_markers(marker_path, strict=True):
|
|
885
|
+
_write_markers(marker_path, normalized)
|
|
886
|
+
stage = "stamp_log_failed"
|
|
887
|
+
_log_event(
|
|
888
|
+
"stamp-started",
|
|
889
|
+
git_dir=str(git_dir),
|
|
890
|
+
sha=sha,
|
|
891
|
+
session_ids=",".join(sorted({key[1] for key in snapshot})),
|
|
892
|
+
)
|
|
893
|
+
stage = "note_read_failed"
|
|
894
|
+
sessions = {
|
|
895
|
+
(s["tool"], s["session_id"]): s
|
|
896
|
+
for s in _read_note_sessions(sha, cwd, strict=True)
|
|
897
|
+
}
|
|
898
|
+
stamped_at = _now_iso()
|
|
899
|
+
for tool, session_id in snapshot:
|
|
900
|
+
sessions.setdefault(
|
|
901
|
+
(tool, session_id),
|
|
902
|
+
{
|
|
903
|
+
"tool": tool,
|
|
904
|
+
"session_id": session_id,
|
|
905
|
+
"stamped_at": stamped_at,
|
|
906
|
+
},
|
|
907
|
+
)
|
|
908
|
+
payload = json.dumps(
|
|
909
|
+
{
|
|
910
|
+
"v": SCHEMA_VERSION,
|
|
911
|
+
"sessions": [sessions[key] for key in sorted(sessions)],
|
|
912
|
+
}
|
|
913
|
+
)
|
|
914
|
+
stage = "note_write_failed"
|
|
915
|
+
result = subprocess.run(
|
|
916
|
+
["git", "notes", f"--ref={NOTES_REF}", "add", "-f", "-F", "-", sha],
|
|
917
|
+
input=payload,
|
|
918
|
+
cwd=cwd,
|
|
919
|
+
capture_output=True,
|
|
920
|
+
text=True,
|
|
921
|
+
timeout=30,
|
|
922
|
+
)
|
|
923
|
+
if result.returncode != 0:
|
|
924
|
+
raise _CaptureFailure("note_write_failed")
|
|
925
|
+
# Log the pinned target before cleanup, including interrupted cleanup.
|
|
926
|
+
stage = "stamp_log_failed"
|
|
927
|
+
_log_event(
|
|
928
|
+
"note-created",
|
|
929
|
+
git_dir=str(git_dir),
|
|
930
|
+
sha=sha,
|
|
931
|
+
session_ids=",".join(sorted({s[1] for s in sessions})),
|
|
932
|
+
)
|
|
933
|
+
stage = "stamp_cleanup_failed"
|
|
934
|
+
try:
|
|
935
|
+
with _file_lock(
|
|
936
|
+
git_dir / MARKER_LOCK_FILENAME, "marker_busy", timeout=1
|
|
937
|
+
):
|
|
938
|
+
live = _marker_entries(marker_path)
|
|
939
|
+
remaining = [
|
|
940
|
+
entry
|
|
941
|
+
for key, entry in live.items()
|
|
942
|
+
if key not in snapshot
|
|
943
|
+
or entry["generation"] != snapshot[key]["generation"]
|
|
944
|
+
]
|
|
945
|
+
_write_markers(marker_path, remaining)
|
|
946
|
+
except _CaptureFailure as exc:
|
|
947
|
+
reason = (
|
|
948
|
+
"stamp_cleanup_unconfirmed"
|
|
949
|
+
if exc.reason == "marker_durability_unconfirmed"
|
|
950
|
+
else "stamp_cleanup_failed"
|
|
951
|
+
)
|
|
952
|
+
raise _CaptureFailure(reason) from None
|
|
953
|
+
except _CaptureFailure as exc:
|
|
954
|
+
_capture_failure(exc.reason, git_dir, sha)
|
|
955
|
+
except subprocess.TimeoutExpired:
|
|
956
|
+
_capture_failure("note_timeout", git_dir, sha)
|
|
957
|
+
except Exception:
|
|
958
|
+
_capture_failure(stage, git_dir, sha)
|
|
959
|
+
return 0
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
_SQUASH_COMMIT_SHA_RE = re.compile(r"^commit ([0-9a-f]{40})$", re.MULTILINE)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _read_note_sessions(
|
|
966
|
+
sha: str, cwd: str | Path, *, strict: bool = False
|
|
967
|
+
) -> list[dict]:
|
|
968
|
+
"""The ``sessions`` list from ``sha``'s note, or ``[]`` if it has none.
|
|
969
|
+
|
|
970
|
+
Tolerant of whitespace-concatenated payloads: ``cmd_stamp``'s post-commit
|
|
971
|
+
``git notes add -f`` and the ``notes.rewriteRef`` rewrite that a
|
|
972
|
+
``git commit --amend``/rebase triggers can both attach a note to the same
|
|
973
|
+
new HEAD, and (``notes.rewriteMode`` defaulting to ``concatenate``) the
|
|
974
|
+
rewrite appends the copied note to the stamped one instead of replacing
|
|
975
|
+
it. The server-side parser already unions such concatenated payloads
|
|
976
|
+
(``sediment_derive/notes.py::_parse_note_body``); this reader must too,
|
|
977
|
+
or a squash-merge of a branch containing an amended-with-new-marker commit
|
|
978
|
+
drops that commit's sessions from the squash union.
|
|
979
|
+
"""
|
|
980
|
+
try:
|
|
981
|
+
result = subprocess.run(
|
|
982
|
+
["git", "notes", f"--ref={NOTES_REF}", "show", sha],
|
|
983
|
+
cwd=cwd,
|
|
984
|
+
capture_output=True,
|
|
985
|
+
text=True,
|
|
986
|
+
timeout=30,
|
|
987
|
+
env={**os.environ, "LC_ALL": "C"},
|
|
988
|
+
)
|
|
989
|
+
if result.returncode != 0:
|
|
990
|
+
# Only this explicit missing-note result permits a first write.
|
|
991
|
+
if (
|
|
992
|
+
result.returncode == 1
|
|
993
|
+
and result.stderr.strip() == f"error: no note found for object {sha}."
|
|
994
|
+
):
|
|
995
|
+
return []
|
|
996
|
+
raise _CaptureFailure("note_read_failed")
|
|
997
|
+
return _parse_note_sessions(result.stdout)
|
|
998
|
+
except _CaptureFailure:
|
|
999
|
+
if strict:
|
|
1000
|
+
raise
|
|
1001
|
+
except subprocess.TimeoutExpired:
|
|
1002
|
+
if strict:
|
|
1003
|
+
raise _CaptureFailure("note_timeout") from None
|
|
1004
|
+
except (OSError, UnicodeError):
|
|
1005
|
+
if strict:
|
|
1006
|
+
raise _CaptureFailure("note_read_failed") from None
|
|
1007
|
+
return []
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _parse_note_sessions(raw: str) -> list[dict]:
|
|
1011
|
+
"""Stdlib equivalent of the canonical v1 concatenated-note contract."""
|
|
1012
|
+
try:
|
|
1013
|
+
if not raw.strip() or len(raw.encode("utf-8")) > 64 * 1024:
|
|
1014
|
+
raise ValueError
|
|
1015
|
+
sessions: dict[tuple[str, str], dict] = {}
|
|
1016
|
+
decoder = json.JSONDecoder()
|
|
1017
|
+
index = 0
|
|
1018
|
+
while index < len(raw):
|
|
1019
|
+
if raw[index].isspace():
|
|
1020
|
+
index += 1
|
|
1021
|
+
continue
|
|
1022
|
+
payload, index = decoder.raw_decode(raw, index)
|
|
1023
|
+
if (
|
|
1024
|
+
not isinstance(payload, dict)
|
|
1025
|
+
or set(payload) != {"v", "sessions"}
|
|
1026
|
+
or type(payload["v"]) is not int
|
|
1027
|
+
or payload["v"] != SCHEMA_VERSION
|
|
1028
|
+
or not isinstance(payload["sessions"], list)
|
|
1029
|
+
):
|
|
1030
|
+
raise ValueError
|
|
1031
|
+
for session in payload["sessions"]:
|
|
1032
|
+
if (
|
|
1033
|
+
not isinstance(session, dict)
|
|
1034
|
+
or set(session) != {"tool", "session_id", "stamped_at"}
|
|
1035
|
+
or not all(isinstance(value, str) for value in session.values())
|
|
1036
|
+
):
|
|
1037
|
+
raise ValueError
|
|
1038
|
+
sessions.setdefault((session["tool"], session["session_id"]), session)
|
|
1039
|
+
return list(sessions.values())
|
|
1040
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
1041
|
+
raise _CaptureFailure("note_invalid") from None
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def cmd_union_squash_notes(msg_file: str, source: str) -> int:
|
|
1045
|
+
"""prepare-commit-msg: fold squashed commits' notes into local markers.
|
|
1046
|
+
|
|
1047
|
+
``git merge --squash`` creates one brand-new commit; note-rewrite copying
|
|
1048
|
+
(``notes.rewriteRef``) only applies to amend/rebase/filter-branch, never
|
|
1049
|
+
to a squash-merge's new commit, and by the time it lands each squashed
|
|
1050
|
+
branch commit has already cleared its own markers (stamped individually
|
|
1051
|
+
when it was made — see ``cmd_stamp``). Nothing at the squash commit's own
|
|
1052
|
+
post-commit stamp step can recover that attribution on its own.
|
|
1053
|
+
|
|
1054
|
+
Deliberately does **not** gate on ``source`` (git's classification,
|
|
1055
|
+
``prepare-commit-msg``'s second argument) or read from ``msg_file``
|
|
1056
|
+
(git's third positional arg, unused here): ``source`` is only
|
|
1057
|
+
``"squash"`` when the commit editor would show the unedited
|
|
1058
|
+
``SQUASH_MSG`` content verbatim — an explicit ``git commit -m "..."``
|
|
1059
|
+
after the squash (the common case; most developers write their own
|
|
1060
|
+
message) classifies as ``"message"`` instead, even though
|
|
1061
|
+
``.git/SQUASH_MSG`` still exists and still lists the squashed SHAs, and
|
|
1062
|
+
``msg_file`` then holds only the developer's own text, not the squash
|
|
1063
|
+
list. Checking ``<git-dir>/SQUASH_MSG`` directly, and reading the
|
|
1064
|
+
squashed SHAs from *that* file, is the reliable signal regardless of how
|
|
1065
|
+
the commit message was supplied. Reading each listed SHA's
|
|
1066
|
+
already-written note and unioning those sessions into the local marker
|
|
1067
|
+
file lets the *existing* post-commit ``stamp`` step write a correct
|
|
1068
|
+
union note on the squash commit, with no new note-writing logic.
|
|
1069
|
+
"""
|
|
1070
|
+
git_dir = None
|
|
1071
|
+
try:
|
|
1072
|
+
cwd = os.getcwd()
|
|
1073
|
+
git_dir = _git_dir(cwd)
|
|
1074
|
+
if git_dir is None:
|
|
1075
|
+
return 0
|
|
1076
|
+
squash_msg = git_dir / "SQUASH_MSG"
|
|
1077
|
+
if not squash_msg.exists():
|
|
1078
|
+
return 0
|
|
1079
|
+
text = squash_msg.read_text(encoding="utf-8")
|
|
1080
|
+
shas = _SQUASH_COMMIT_SHA_RE.findall(text)
|
|
1081
|
+
if not shas:
|
|
1082
|
+
return 0
|
|
1083
|
+
unioned: dict[tuple[str, str], dict] = {}
|
|
1084
|
+
for sha in shas:
|
|
1085
|
+
for session in _read_note_sessions(sha, cwd, strict=True):
|
|
1086
|
+
unioned.setdefault((session["tool"], session["session_id"]), session)
|
|
1087
|
+
if not unioned:
|
|
1088
|
+
return 0
|
|
1089
|
+
marker_path = git_dir / MARKER_FILENAME
|
|
1090
|
+
with _file_lock(git_dir / MARKER_LOCK_FILENAME, "marker_busy", timeout=1):
|
|
1091
|
+
live = _marker_entries(marker_path)
|
|
1092
|
+
for key, session in unioned.items():
|
|
1093
|
+
if key not in live:
|
|
1094
|
+
live[key] = {**session, "generation": str(uuid4())}
|
|
1095
|
+
_write_markers(marker_path, list(live.values()))
|
|
1096
|
+
except _CaptureFailure as exc:
|
|
1097
|
+
_capture_failure(exc.reason, git_dir)
|
|
1098
|
+
except Exception:
|
|
1099
|
+
_capture_failure("marker_write_failed", git_dir)
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
# Where the remote's notes land during reconcile. A plain tracking ref, so a
|
|
1104
|
+
# failed merge can never damage the real local ref.
|
|
1105
|
+
NOTES_REMOTE_TRACKING_REF = "refs/notes/sediment-remote"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _reconcile_notes(remote: str) -> bool:
|
|
1109
|
+
"""Union-merge the remote's notes ref into the local one, best-effort.
|
|
1110
|
+
|
|
1111
|
+
A notes ref is an ordinary commit-ish ref, so a plain push only succeeds
|
|
1112
|
+
when local is a fast-forward of remote — a machine that stamped before
|
|
1113
|
+
ever fetching the remote notes builds a disjoint root and can never push
|
|
1114
|
+
again. Reconciling first repairs that state and keeps it from arising.
|
|
1115
|
+
``cat_sort_uniq`` is lossless under the reader contract: the
|
|
1116
|
+
server-side parser (sediment_derive/notes.py::_parse_note_body) already
|
|
1117
|
+
parses any-whitespace-concatenated payloads and unions sessions per
|
|
1118
|
+
(tool, session_id), so a line-level union merge is safe and idempotent.
|
|
1119
|
+
"""
|
|
1120
|
+
git_dir = None
|
|
1121
|
+
try:
|
|
1122
|
+
git_dir = _git_dir(os.getcwd())
|
|
1123
|
+
with _file_lock(_notes_lock_path(os.getcwd()), "notes_reconcile_busy"):
|
|
1124
|
+
return _reconcile_notes_locked(remote)
|
|
1125
|
+
except _CaptureFailure as exc:
|
|
1126
|
+
_capture_failure(exc.reason, git_dir)
|
|
1127
|
+
except Exception:
|
|
1128
|
+
_capture_failure("notes_reconcile_failed", git_dir)
|
|
1129
|
+
return False
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _reconcile_notes_locked(remote: str) -> bool:
|
|
1133
|
+
"""The caller owns the common notes mutex, including the tracking-ref fetch."""
|
|
1134
|
+
if _git(["fetch", remote, f"+{NOTES_REF}:{NOTES_REMOTE_TRACKING_REF}"]) is None:
|
|
1135
|
+
return True # remote unreachable or has no notes ref yet — the push decides
|
|
1136
|
+
if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
|
|
1137
|
+
# Fresh machine: adopt the remote's history outright.
|
|
1138
|
+
if _git(["update-ref", NOTES_REF, NOTES_REMOTE_TRACKING_REF]) is None:
|
|
1139
|
+
raise _CaptureFailure("notes_reconcile_failed")
|
|
1140
|
+
return True
|
|
1141
|
+
if (
|
|
1142
|
+
_git(["merge-base", "--is-ancestor", NOTES_REMOTE_TRACKING_REF, NOTES_REF])
|
|
1143
|
+
is not None
|
|
1144
|
+
):
|
|
1145
|
+
return True # local already contains the remote's history
|
|
1146
|
+
if (
|
|
1147
|
+
_git(
|
|
1148
|
+
[
|
|
1149
|
+
"notes",
|
|
1150
|
+
f"--ref={NOTES_REF}",
|
|
1151
|
+
"merge",
|
|
1152
|
+
"-s",
|
|
1153
|
+
"cat_sort_uniq",
|
|
1154
|
+
NOTES_REMOTE_TRACKING_REF,
|
|
1155
|
+
]
|
|
1156
|
+
)
|
|
1157
|
+
is None
|
|
1158
|
+
):
|
|
1159
|
+
raise _CaptureFailure("notes_reconcile_failed")
|
|
1160
|
+
return True
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _reconciled_push(remote: str) -> subprocess.CompletedProcess:
|
|
1164
|
+
"""Reconcile then push the notes ref, retrying once if the remote moved.
|
|
1165
|
+
|
|
1166
|
+
A concurrent push from another machine moving the remote tip
|
|
1167
|
+
mid-operation is expected, not exceptional — it has happened in
|
|
1168
|
+
practice.
|
|
1169
|
+
"""
|
|
1170
|
+
env = dict(os.environ, **{PUSH_GUARD_ENV: "1"})
|
|
1171
|
+
result: subprocess.CompletedProcess
|
|
1172
|
+
for _attempt in range(2):
|
|
1173
|
+
if not _reconcile_notes(remote):
|
|
1174
|
+
return subprocess.CompletedProcess(
|
|
1175
|
+
["git", "push"], 1, "", "notes_reconcile_incomplete"
|
|
1176
|
+
)
|
|
1177
|
+
result = subprocess.run(
|
|
1178
|
+
["git", "push", remote, f"{NOTES_REF}:{NOTES_REF}"],
|
|
1179
|
+
capture_output=True,
|
|
1180
|
+
text=True,
|
|
1181
|
+
timeout=120,
|
|
1182
|
+
env=env,
|
|
1183
|
+
)
|
|
1184
|
+
if result.returncode == 0:
|
|
1185
|
+
break
|
|
1186
|
+
return result
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _push_failure_detail(result: subprocess.CompletedProcess) -> str:
|
|
1190
|
+
"""Last stderr line, else last stdout line, else the exit code.
|
|
1191
|
+
|
|
1192
|
+
A ``git push`` can fail with a message only on stdout (a remote-side
|
|
1193
|
+
pre-receive hook, for example); ``exit N`` alone names nothing.
|
|
1194
|
+
"""
|
|
1195
|
+
stderr = result.stderr.strip().splitlines()
|
|
1196
|
+
if stderr:
|
|
1197
|
+
return stderr[-1]
|
|
1198
|
+
stdout = result.stdout.strip().splitlines()
|
|
1199
|
+
if stdout:
|
|
1200
|
+
return stdout[-1]
|
|
1201
|
+
return f"exit {result.returncode}"
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _log_push_failure(remote: str, detail: str) -> None:
|
|
1205
|
+
"""Log a ``notes-push-failed`` event naming the checkout when known.
|
|
1206
|
+
|
|
1207
|
+
``git_dir`` matches ``unhooked-repo`` so ``doctor`` can point at the
|
|
1208
|
+
failing checkout the way it points at unhooked ones.
|
|
1209
|
+
"""
|
|
1210
|
+
fields: dict[str, str] = {"remote": remote, "detail": detail}
|
|
1211
|
+
git_dir = _git_dir(os.getcwd())
|
|
1212
|
+
if git_dir is not None:
|
|
1213
|
+
fields["git_dir"] = str(git_dir)
|
|
1214
|
+
_log_event("notes-push-failed", **fields)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def cmd_push_notes(remote: str) -> int:
|
|
1218
|
+
"""pre-push: reconcile then push the notes ref, best-effort.
|
|
1219
|
+
|
|
1220
|
+
A final failure prints one stderr line and logs a ``notes-push-failed``
|
|
1221
|
+
event to the local attribution log — a stderr line inside ``git push``
|
|
1222
|
+
output is not a signal anyone sees.
|
|
1223
|
+
|
|
1224
|
+
Guarded against recursion (our own `git push` fires pre-push again) via an
|
|
1225
|
+
environment variable rather than parsing the hook's stdin, so it composes
|
|
1226
|
+
with other pre-push hooks that may have consumed stdin already.
|
|
1227
|
+
"""
|
|
1228
|
+
try:
|
|
1229
|
+
if os.environ.get(PUSH_GUARD_ENV):
|
|
1230
|
+
return 0
|
|
1231
|
+
if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
|
|
1232
|
+
return 0 # nothing to push
|
|
1233
|
+
result = _reconciled_push(remote)
|
|
1234
|
+
if result.returncode != 0:
|
|
1235
|
+
detail = _push_failure_detail(result)
|
|
1236
|
+
print(
|
|
1237
|
+
f"sediment-attribution: notes push to {remote} failed "
|
|
1238
|
+
f"(push continues): {detail}",
|
|
1239
|
+
file=sys.stderr,
|
|
1240
|
+
)
|
|
1241
|
+
_log_push_failure(remote, detail)
|
|
1242
|
+
except Exception:
|
|
1243
|
+
pass # never abort the developer's push
|
|
1244
|
+
return 0
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def cmd_repair_notes(remote: str) -> int:
|
|
1248
|
+
"""Operator command: reconcile the notes ref with ``remote`` and push.
|
|
1249
|
+
|
|
1250
|
+
The on-demand fix for a machine already in the diverged state, safe to
|
|
1251
|
+
run any time — fetch, union merge, push are each idempotent. On a fresh
|
|
1252
|
+
machine with no local notes ref it adopts the remote's. Unlike the
|
|
1253
|
+
hooks this is NOT best-effort: it reports what happened and exits
|
|
1254
|
+
non-zero on failure so operators and scripts can trust the result.
|
|
1255
|
+
"""
|
|
1256
|
+
if _git(["rev-parse", "--is-inside-work-tree"]) != "true":
|
|
1257
|
+
print("repair-notes: not inside a git work tree", file=sys.stderr)
|
|
1258
|
+
return 1
|
|
1259
|
+
if not _reconcile_notes(remote):
|
|
1260
|
+
return 1
|
|
1261
|
+
if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
|
|
1262
|
+
print(f"repair-notes: no notes ref locally or on {remote}; nothing to do")
|
|
1263
|
+
return 0
|
|
1264
|
+
result = _reconciled_push(remote)
|
|
1265
|
+
if result.returncode != 0:
|
|
1266
|
+
detail = _push_failure_detail(result)
|
|
1267
|
+
print(f"repair-notes: push to {remote} failed: {detail}", file=sys.stderr)
|
|
1268
|
+
_log_push_failure(remote, detail)
|
|
1269
|
+
return 1
|
|
1270
|
+
print(
|
|
1271
|
+
f"{ui.glyph('✓', 'phosphor')}repair-notes: notes ref reconciled "
|
|
1272
|
+
f"and pushed to {remote}"
|
|
1273
|
+
)
|
|
1274
|
+
return 0
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
# ── doctor ────────────────────────────────────────────────────────────────
|
|
1278
|
+
#
|
|
1279
|
+
# `doctor` detects missing agent-clone hooks, diverged notes refs, and hook
|
|
1280
|
+
# sets missing prepare-commit-msg by inspecting local configuration and refs.
|
|
1281
|
+
#
|
|
1282
|
+
# Read-only by default: it never writes a ref, a config, or a hook. The one
|
|
1283
|
+
# state it cannot classify without a write is the notes ref when the remote
|
|
1284
|
+
# tip is not a local object — `--fetch` opts into the tracking-ref fetch
|
|
1285
|
+
# that resolves it, and is the flag a diverged machine needs (see
|
|
1286
|
+
# ``_doctor_notes_state``).
|
|
1287
|
+
|
|
1288
|
+
# Status words. Only DOCTOR_FAIL moves the exit code.
|
|
1289
|
+
DOCTOR_FAIL = "FAIL"
|
|
1290
|
+
DOCTOR_OK = "ok"
|
|
1291
|
+
# `info` is a fact the operator may want and no verdict — an unset fleet
|
|
1292
|
+
# templateDir, unpushed notes. Never counts toward the exit code: a doctor
|
|
1293
|
+
# that goes red for normal states is a doctor operators learn to ignore.
|
|
1294
|
+
DOCTOR_INFO = "info"
|
|
1295
|
+
|
|
1296
|
+
# Any invocation generation: *attribution.py (legacy script or packaged
|
|
1297
|
+
# module) or the installed `sediment` executable — doctor existence-checks
|
|
1298
|
+
# whichever one the hook references.
|
|
1299
|
+
_SCRIPT_PATH_RE = re.compile(
|
|
1300
|
+
r'"([^"]*(?:(?:attribution|transcript)\.py|/sediment(?:\.exe)?))"'
|
|
1301
|
+
)
|
|
1302
|
+
# Log events worth surfacing: each one records a stamp that did not happen.
|
|
1303
|
+
_DOCTOR_LOG_EVENTS = (
|
|
1304
|
+
"unhooked-repo",
|
|
1305
|
+
"notes-push-failed",
|
|
1306
|
+
"config-error",
|
|
1307
|
+
*_CAPTURE_FAILURE_REASONS,
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
Finding = tuple[str, str, str]
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _parse_instant(value: str) -> datetime | None:
|
|
1314
|
+
"""An offset-aware datetime from an ISO-8601 timestamp, or None.
|
|
1315
|
+
|
|
1316
|
+
Both timestamps doctor compares are written by tools that always emit an
|
|
1317
|
+
offset (``_now_iso`` in UTC, git ``%cI`` in the committer's local zone),
|
|
1318
|
+
so a naive result means the input was not one of ours — treated as
|
|
1319
|
+
unreadable rather than silently assumed to be UTC.
|
|
1320
|
+
"""
|
|
1321
|
+
try:
|
|
1322
|
+
parsed = datetime.fromisoformat(value)
|
|
1323
|
+
except ValueError:
|
|
1324
|
+
return None
|
|
1325
|
+
return parsed if parsed.tzinfo is not None else None
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _max_instant(values: Iterable[str]) -> datetime | None:
|
|
1329
|
+
parsed = [instant for value in values if (instant := _parse_instant(value))]
|
|
1330
|
+
return max(parsed) if parsed else None
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _hook_block_body(hook_file: Path) -> str | None:
|
|
1334
|
+
"""Our marked block's contents in ``hook_file``, or None when absent.
|
|
1335
|
+
|
|
1336
|
+
A missing file, a binary one, and a file whose block was deleted by hand
|
|
1337
|
+
all read the same: absent. Callers pass the path under the hooks dir git
|
|
1338
|
+
will actually execute (``_hooks_dir``), never a guessed one.
|
|
1339
|
+
"""
|
|
1340
|
+
try:
|
|
1341
|
+
content = hook_file.read_text(encoding="utf-8")
|
|
1342
|
+
except (OSError, UnicodeDecodeError):
|
|
1343
|
+
return None
|
|
1344
|
+
match = _HOOK_BLOCK_RE.search(content)
|
|
1345
|
+
return match.group(1) if match else None
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _referenced_script(block: str) -> Path | None:
|
|
1349
|
+
"""The stamper path a hook block or agent hook command invokes.
|
|
1350
|
+
|
|
1351
|
+
Both invocation builders quote it (``_script_invocation`` uses
|
|
1352
|
+
``sys.executable``, ``_fleet_invocation`` a PATH ``python3``), so one
|
|
1353
|
+
quoted-path match covers per-repo and fleet installs alike.
|
|
1354
|
+
"""
|
|
1355
|
+
match = _SCRIPT_PATH_RE.search(block)
|
|
1356
|
+
return Path(match.group(1)) if match else None
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _iter_hooks(blocks: list) -> Iterator[dict]:
|
|
1360
|
+
"""Every hook entry inside an agent config's event list. Shapes that are
|
|
1361
|
+
not ours to read are skipped rather than raised on — a hand-edited config
|
|
1362
|
+
must never crash install, uninstall, or doctor."""
|
|
1363
|
+
for block in blocks:
|
|
1364
|
+
if isinstance(block, dict):
|
|
1365
|
+
for hook in block.get("hooks", []):
|
|
1366
|
+
if isinstance(hook, dict):
|
|
1367
|
+
yield hook
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _agent_hook_commands(path: Path, event: str) -> list[str] | None:
|
|
1371
|
+
"""Our ``mark`` commands under ``event`` in an agent config.
|
|
1372
|
+
|
|
1373
|
+
``[]`` means the file parsed and holds none of ours; None means the file
|
|
1374
|
+
exists but cannot be trusted (unreadable, invalid JSON, wrong shape) —
|
|
1375
|
+
a different finding than absent, and the same refusal ``install`` makes.
|
|
1376
|
+
"""
|
|
1377
|
+
if not path.exists():
|
|
1378
|
+
return []
|
|
1379
|
+
config = _load_json(path)
|
|
1380
|
+
if config is None:
|
|
1381
|
+
return None
|
|
1382
|
+
hooks = config.get("hooks", {})
|
|
1383
|
+
if not isinstance(hooks, dict):
|
|
1384
|
+
return None
|
|
1385
|
+
blocks = hooks.get(event, [])
|
|
1386
|
+
if not isinstance(blocks, list):
|
|
1387
|
+
return None
|
|
1388
|
+
return [
|
|
1389
|
+
hook["command"]
|
|
1390
|
+
for hook in _iter_hooks(blocks)
|
|
1391
|
+
if isinstance(hook.get("command"), str)
|
|
1392
|
+
and _is_sediment_command(hook["command"])
|
|
1393
|
+
]
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _doctor_agent_hook(
|
|
1397
|
+
check: str,
|
|
1398
|
+
paths: list[Path],
|
|
1399
|
+
subcommand: str,
|
|
1400
|
+
*,
|
|
1401
|
+
event: str = "PostToolUse",
|
|
1402
|
+
required: bool = True,
|
|
1403
|
+
) -> Finding:
|
|
1404
|
+
"""One finding for an agent hook entry that may live in several files.
|
|
1405
|
+
|
|
1406
|
+
Claude Code reads an MDM-managed file *and* the per-user one; either
|
|
1407
|
+
carrying the entry means the agent marks. A file that does not parse is
|
|
1408
|
+
reported as its own failure rather than counted as absent — that is the
|
|
1409
|
+
state where ``install`` refuses to write and says nothing else.
|
|
1410
|
+
|
|
1411
|
+
An agent this machine does not have reports info, never FAIL —
|
|
1412
|
+
``install`` skips it on the same presence gate, and a doctor that goes
|
|
1413
|
+
red for a Codex-less machine gets ignored (``_doctor_pi_extension``
|
|
1414
|
+
already carries this rule).
|
|
1415
|
+
"""
|
|
1416
|
+
if not any(path.parent.exists() for path in paths):
|
|
1417
|
+
return (
|
|
1418
|
+
DOCTOR_INFO,
|
|
1419
|
+
check,
|
|
1420
|
+
f"not detected ({paths[-1].parent} does not exist)",
|
|
1421
|
+
)
|
|
1422
|
+
searched = []
|
|
1423
|
+
for path in paths:
|
|
1424
|
+
commands = _agent_hook_commands(path, event)
|
|
1425
|
+
if commands is None:
|
|
1426
|
+
return (
|
|
1427
|
+
DOCTOR_FAIL,
|
|
1428
|
+
check,
|
|
1429
|
+
f"{path} is not valid JSON — install refuses it",
|
|
1430
|
+
)
|
|
1431
|
+
matching = [
|
|
1432
|
+
c
|
|
1433
|
+
for c in commands
|
|
1434
|
+
if subcommand in c
|
|
1435
|
+
or (
|
|
1436
|
+
subcommand.startswith(" transcript ")
|
|
1437
|
+
and subcommand.removeprefix(" transcript") in c
|
|
1438
|
+
and any(
|
|
1439
|
+
tag in c
|
|
1440
|
+
for tag in ("sediment_transcript.py", "sediment_cli/transcript.py")
|
|
1441
|
+
)
|
|
1442
|
+
)
|
|
1443
|
+
]
|
|
1444
|
+
if matching:
|
|
1445
|
+
script = _referenced_script(matching[0])
|
|
1446
|
+
if script is not None and not script.exists():
|
|
1447
|
+
return (
|
|
1448
|
+
DOCTOR_FAIL,
|
|
1449
|
+
check,
|
|
1450
|
+
f"{path} invokes {script}, which does not exist — "
|
|
1451
|
+
"re-run install to repoint it",
|
|
1452
|
+
)
|
|
1453
|
+
return (DOCTOR_OK, check, f"present in {path}")
|
|
1454
|
+
searched.append(str(path))
|
|
1455
|
+
if required:
|
|
1456
|
+
return (
|
|
1457
|
+
DOCTOR_FAIL,
|
|
1458
|
+
check,
|
|
1459
|
+
f"no '{subcommand}' entry in {' or '.join(searched)} — "
|
|
1460
|
+
"sessions are never marked; run install",
|
|
1461
|
+
)
|
|
1462
|
+
return (
|
|
1463
|
+
DOCTOR_INFO,
|
|
1464
|
+
check,
|
|
1465
|
+
f"not installed in {' or '.join(searched)} (opt in with --transcripts)",
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _doctor_fleet(findings: list[Finding]) -> None:
|
|
1470
|
+
"""Check ``init.templateDir`` — the fleet's only stamping mechanism.
|
|
1471
|
+
|
|
1472
|
+
Reads the *effective* value rather than the system scope alone: a global
|
|
1473
|
+
or repo-level override is what git will really use when cloning, and a
|
|
1474
|
+
fleet check that misses the override reports a template that is not
|
|
1475
|
+
actually in play.
|
|
1476
|
+
"""
|
|
1477
|
+
template_dir = _git(["config", "--get", "init.templateDir"])
|
|
1478
|
+
if not template_dir:
|
|
1479
|
+
findings.append(
|
|
1480
|
+
(
|
|
1481
|
+
DOCTOR_INFO,
|
|
1482
|
+
"fleet template",
|
|
1483
|
+
"init.templateDir unset — not a fleet machine",
|
|
1484
|
+
)
|
|
1485
|
+
)
|
|
1486
|
+
return
|
|
1487
|
+
if not _is_our_template_dir(template_dir):
|
|
1488
|
+
findings.append(
|
|
1489
|
+
(
|
|
1490
|
+
DOCTOR_FAIL,
|
|
1491
|
+
"fleet template",
|
|
1492
|
+
f"init.templateDir is {template_dir}, whose post-commit is not "
|
|
1493
|
+
"sediment's — new clones will not stamp",
|
|
1494
|
+
)
|
|
1495
|
+
)
|
|
1496
|
+
return
|
|
1497
|
+
block = _hook_block_body(Path(template_dir) / "hooks" / "post-commit")
|
|
1498
|
+
script = _referenced_script(block or "")
|
|
1499
|
+
if script is None:
|
|
1500
|
+
findings.append(
|
|
1501
|
+
(
|
|
1502
|
+
DOCTOR_FAIL,
|
|
1503
|
+
"fleet template",
|
|
1504
|
+
f"{template_dir} post-commit names no stamper script — "
|
|
1505
|
+
"re-run install --fleet",
|
|
1506
|
+
)
|
|
1507
|
+
)
|
|
1508
|
+
return
|
|
1509
|
+
if not script.exists():
|
|
1510
|
+
findings.append(
|
|
1511
|
+
(
|
|
1512
|
+
DOCTOR_FAIL,
|
|
1513
|
+
"fleet template",
|
|
1514
|
+
f"{template_dir} invokes {script}, which does not exist — "
|
|
1515
|
+
"every clone stamps nothing",
|
|
1516
|
+
)
|
|
1517
|
+
)
|
|
1518
|
+
return
|
|
1519
|
+
# Byte comparison, because the script carries no version constant. A
|
|
1520
|
+
# difference is not a failure: doctor may be running from a checkout at a
|
|
1521
|
+
# different revision than the deployed copy, which is normal and says
|
|
1522
|
+
# nothing about which is newer.
|
|
1523
|
+
running = Path(__file__).resolve()
|
|
1524
|
+
try:
|
|
1525
|
+
differs = (
|
|
1526
|
+
script.resolve() != running and script.read_bytes() != running.read_bytes()
|
|
1527
|
+
)
|
|
1528
|
+
except OSError as exc:
|
|
1529
|
+
# e.g. a root-owned 0600 deployed copy under an unprivileged doctor
|
|
1530
|
+
# run. Degrade to a finding like every sibling read; a traceback here
|
|
1531
|
+
# would swallow the rest of the report.
|
|
1532
|
+
findings.append(
|
|
1533
|
+
(
|
|
1534
|
+
DOCTOR_INFO,
|
|
1535
|
+
"fleet template",
|
|
1536
|
+
f"could not read {script} to compare revisions: {exc}",
|
|
1537
|
+
)
|
|
1538
|
+
)
|
|
1539
|
+
return
|
|
1540
|
+
if differs:
|
|
1541
|
+
findings.append(
|
|
1542
|
+
(
|
|
1543
|
+
DOCTOR_INFO,
|
|
1544
|
+
"fleet template",
|
|
1545
|
+
f"{script} differs from the running {running} — "
|
|
1546
|
+
"confirm which revision the fleet should be on",
|
|
1547
|
+
)
|
|
1548
|
+
)
|
|
1549
|
+
return
|
|
1550
|
+
findings.append((DOCTOR_OK, "fleet template", f"{template_dir} invokes {script}"))
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _doctor_log(findings: list[Finding]) -> None:
|
|
1554
|
+
"""Summarize the attribution log's misses, and name the repos to re-run in.
|
|
1555
|
+
|
|
1556
|
+
Informational on purpose. These are historical events, and the live
|
|
1557
|
+
checks above and below decide the exit code: failing on the log would
|
|
1558
|
+
keep doctor red long after the cause was fixed, until someone deleted a
|
|
1559
|
+
file. The value here is the pointer — an unhooked repo nobody passed to
|
|
1560
|
+
doctor is otherwise invisible, which is exactly how it stayed hidden for
|
|
1561
|
+
months.
|
|
1562
|
+
"""
|
|
1563
|
+
path = _log_path()
|
|
1564
|
+
counts: dict[str, int] = {}
|
|
1565
|
+
repos: list[str] = []
|
|
1566
|
+
stamps: list[str] = []
|
|
1567
|
+
try:
|
|
1568
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
1569
|
+
except OSError:
|
|
1570
|
+
findings.append((DOCTOR_INFO, "attribution log", f"{path}: no events recorded"))
|
|
1571
|
+
return
|
|
1572
|
+
for line in lines:
|
|
1573
|
+
try:
|
|
1574
|
+
entry = json.loads(line)
|
|
1575
|
+
except ValueError:
|
|
1576
|
+
continue
|
|
1577
|
+
if not isinstance(entry, dict):
|
|
1578
|
+
continue
|
|
1579
|
+
event = entry.get("event")
|
|
1580
|
+
if event not in _DOCTOR_LOG_EVENTS:
|
|
1581
|
+
continue
|
|
1582
|
+
counts[event] = counts.get(event, 0) + 1
|
|
1583
|
+
# Parsed, not string-compared: the log is append-only UTC today, but
|
|
1584
|
+
# a string max would silently mis-order the moment it is not.
|
|
1585
|
+
stamps.append(str(entry.get("at", "")))
|
|
1586
|
+
git_dir = entry.get("git_dir")
|
|
1587
|
+
if (
|
|
1588
|
+
event == "unhooked-repo"
|
|
1589
|
+
and isinstance(git_dir, str)
|
|
1590
|
+
and git_dir not in repos
|
|
1591
|
+
):
|
|
1592
|
+
repos.append(git_dir)
|
|
1593
|
+
if not counts:
|
|
1594
|
+
findings.append((DOCTOR_INFO, "attribution log", f"{path}: no misses recorded"))
|
|
1595
|
+
return
|
|
1596
|
+
summary = ", ".join(f"{count} {event}" for event, count in sorted(counts.items()))
|
|
1597
|
+
latest = _max_instant(stamps)
|
|
1598
|
+
detail = summary
|
|
1599
|
+
if latest is not None:
|
|
1600
|
+
detail += f" (most recent {latest.isoformat(timespec='seconds')})"
|
|
1601
|
+
if repos:
|
|
1602
|
+
detail += f"; unhooked: {', '.join(repos)} — pass these to doctor or install"
|
|
1603
|
+
findings.append((DOCTOR_INFO, "attribution log", detail))
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _doctor_hooks(findings: list[Finding], label: str, repo: Path) -> None:
|
|
1607
|
+
"""Check that all three git hooks carry a block that can actually run.
|
|
1608
|
+
|
|
1609
|
+
Deliberately not an exact match against what ``install`` would write
|
|
1610
|
+
today: a hook installed by a different interpreter, or by a fleet copy
|
|
1611
|
+
at a different prefix, is correct. What must hold is that the block
|
|
1612
|
+
exists, invokes the right subcommand, and names a script still on disk —
|
|
1613
|
+
the three ways a hook set goes stale (missing entry, wrong entry, moved
|
|
1614
|
+
checkout).
|
|
1615
|
+
"""
|
|
1616
|
+
hooks_dir = _hooks_dir(repo)
|
|
1617
|
+
if hooks_dir is None:
|
|
1618
|
+
findings.append(
|
|
1619
|
+
(
|
|
1620
|
+
DOCTOR_FAIL,
|
|
1621
|
+
f"hooks[{label}]",
|
|
1622
|
+
"cannot resolve the hooks dir git would run",
|
|
1623
|
+
)
|
|
1624
|
+
)
|
|
1625
|
+
return
|
|
1626
|
+
broken = []
|
|
1627
|
+
for name, invocation in _REPO_HOOKS:
|
|
1628
|
+
subcommand = invocation.split()[0] # the block also carries "$1"/"$2"
|
|
1629
|
+
block = _hook_block_body(hooks_dir / name)
|
|
1630
|
+
if block is None:
|
|
1631
|
+
broken.append(f"{name} missing")
|
|
1632
|
+
continue
|
|
1633
|
+
if subcommand not in block:
|
|
1634
|
+
broken.append(f"{name} does not invoke {subcommand}")
|
|
1635
|
+
continue
|
|
1636
|
+
script = _referenced_script(block)
|
|
1637
|
+
if script is None:
|
|
1638
|
+
broken.append(f"{name} names no stamper script")
|
|
1639
|
+
elif not script.exists():
|
|
1640
|
+
broken.append(f"{name} invokes {script}, which does not exist")
|
|
1641
|
+
if broken:
|
|
1642
|
+
findings.append(
|
|
1643
|
+
(
|
|
1644
|
+
DOCTOR_FAIL,
|
|
1645
|
+
f"hooks[{label}]",
|
|
1646
|
+
f"{'; '.join(broken)} (in {hooks_dir}) — run install {repo}",
|
|
1647
|
+
)
|
|
1648
|
+
)
|
|
1649
|
+
return
|
|
1650
|
+
findings.append((DOCTOR_OK, f"hooks[{label}]", f"all three current in {hooks_dir}"))
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _doctor_rewrite_ref(findings: list[Finding], label: str, repo: Path) -> None:
|
|
1654
|
+
values = (
|
|
1655
|
+
_git(["config", "--get-all", "notes.rewriteRef"], repo) or ""
|
|
1656
|
+
).splitlines()
|
|
1657
|
+
if NOTES_REF in values:
|
|
1658
|
+
findings.append((DOCTOR_OK, f"notes.rewriteRef[{label}]", NOTES_REF))
|
|
1659
|
+
return
|
|
1660
|
+
findings.append(
|
|
1661
|
+
(
|
|
1662
|
+
DOCTOR_FAIL,
|
|
1663
|
+
f"notes.rewriteRef[{label}]",
|
|
1664
|
+
f"{NOTES_REF} not configured — amend and rebase drop the note; "
|
|
1665
|
+
f"run install {repo}",
|
|
1666
|
+
)
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _doctor_notes_state(
|
|
1671
|
+
findings: list[Finding], label: str, repo: Path, fetch: bool
|
|
1672
|
+
) -> None:
|
|
1673
|
+
"""Classify the notes ref against the remote's, without writing by default.
|
|
1674
|
+
|
|
1675
|
+
The diverged state — local and remote notes histories with no common
|
|
1676
|
+
ancestor — makes every push a silent no-op forever. Classifying it needs
|
|
1677
|
+
both tips as local objects, and the machines that have the bug are
|
|
1678
|
+
precisely the ones that never fetched the remote's notes, so the honest
|
|
1679
|
+
default is to say the tip is unresolvable and name the flag that
|
|
1680
|
+
resolves it. ``--fetch`` writes only ``NOTES_REMOTE_TRACKING_REF``, the
|
|
1681
|
+
same ref ``push-notes`` already owns; the real notes ref is untouched.
|
|
1682
|
+
"""
|
|
1683
|
+
check = f"notes ref[{label}]"
|
|
1684
|
+
remote = _git(["remote", "get-url", "origin"], repo)
|
|
1685
|
+
local = _git(["rev-parse", "--verify", "--quiet", NOTES_REF], repo)
|
|
1686
|
+
if not remote:
|
|
1687
|
+
state = "local only" if local else "none yet"
|
|
1688
|
+
findings.append((DOCTOR_INFO, check, f"no origin remote; {state} locally"))
|
|
1689
|
+
return
|
|
1690
|
+
listing = _git(["ls-remote", "origin", NOTES_REF], repo)
|
|
1691
|
+
if listing is None:
|
|
1692
|
+
# None is command failure (offline, auth, deleted remote) — a
|
|
1693
|
+
# different fact from an empty listing, and asserting "no notes ref
|
|
1694
|
+
# on origin yet" here would report green on the one machine state
|
|
1695
|
+
# this check exists to surface (divergence behind a broken remote).
|
|
1696
|
+
findings.append(
|
|
1697
|
+
(DOCTOR_INFO, check, "could not reach origin to check the notes ref")
|
|
1698
|
+
)
|
|
1699
|
+
return
|
|
1700
|
+
remote_sha = listing.split()[0] if listing else None
|
|
1701
|
+
if remote_sha is None:
|
|
1702
|
+
detail = "no notes ref on origin yet"
|
|
1703
|
+
findings.append(
|
|
1704
|
+
(DOCTOR_INFO, check, f"{detail}; local ref exists" if local else detail)
|
|
1705
|
+
)
|
|
1706
|
+
return
|
|
1707
|
+
if local is None:
|
|
1708
|
+
findings.append(
|
|
1709
|
+
(
|
|
1710
|
+
DOCTOR_INFO,
|
|
1711
|
+
check,
|
|
1712
|
+
f"nothing stamped locally yet; origin has {remote_sha[:8]}",
|
|
1713
|
+
)
|
|
1714
|
+
)
|
|
1715
|
+
return
|
|
1716
|
+
if local == remote_sha:
|
|
1717
|
+
findings.append((DOCTOR_OK, check, f"in sync with origin at {local[:8]}"))
|
|
1718
|
+
return
|
|
1719
|
+
if _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"], repo) is None:
|
|
1720
|
+
if not fetch:
|
|
1721
|
+
findings.append(
|
|
1722
|
+
(
|
|
1723
|
+
DOCTOR_INFO,
|
|
1724
|
+
check,
|
|
1725
|
+
f"origin is at {remote_sha[:8]}, which is not a local object — "
|
|
1726
|
+
"cannot tell behind from diverged read-only, and this check "
|
|
1727
|
+
"cannot fail without it; re-run with --fetch",
|
|
1728
|
+
)
|
|
1729
|
+
)
|
|
1730
|
+
return
|
|
1731
|
+
_git(["fetch", "origin", f"+{NOTES_REF}:{NOTES_REMOTE_TRACKING_REF}"], repo)
|
|
1732
|
+
if _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"], repo) is None:
|
|
1733
|
+
findings.append(
|
|
1734
|
+
(
|
|
1735
|
+
DOCTOR_FAIL,
|
|
1736
|
+
check,
|
|
1737
|
+
f"could not fetch origin's notes ref ({remote_sha[:8]})",
|
|
1738
|
+
)
|
|
1739
|
+
)
|
|
1740
|
+
return
|
|
1741
|
+
ahead = _git(["rev-list", "--count", f"{remote_sha}..{local}"], repo)
|
|
1742
|
+
behind = _git(["rev-list", "--count", f"{local}..{remote_sha}"], repo)
|
|
1743
|
+
if ahead is None or behind is None:
|
|
1744
|
+
findings.append((DOCTOR_FAIL, check, "could not compare the notes refs"))
|
|
1745
|
+
return
|
|
1746
|
+
if behind != "0" and ahead != "0":
|
|
1747
|
+
findings.append(
|
|
1748
|
+
(
|
|
1749
|
+
DOCTOR_FAIL,
|
|
1750
|
+
check,
|
|
1751
|
+
f"DIVERGED from origin ({ahead} local, {behind} remote commits) — "
|
|
1752
|
+
"every push silently drops stamps; fix with "
|
|
1753
|
+
f"repair-notes in {repo}",
|
|
1754
|
+
)
|
|
1755
|
+
)
|
|
1756
|
+
return
|
|
1757
|
+
if ahead != "0":
|
|
1758
|
+
findings.append(
|
|
1759
|
+
(
|
|
1760
|
+
DOCTOR_INFO,
|
|
1761
|
+
check,
|
|
1762
|
+
f"{ahead} commit(s) ahead of origin — the next push carries them",
|
|
1763
|
+
)
|
|
1764
|
+
)
|
|
1765
|
+
return
|
|
1766
|
+
findings.append(
|
|
1767
|
+
(
|
|
1768
|
+
DOCTOR_INFO,
|
|
1769
|
+
check,
|
|
1770
|
+
f"{behind} commit(s) behind origin — the next push reconciles",
|
|
1771
|
+
)
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def _doctor_markers(findings: list[Finding], label: str, repo: Path) -> None:
|
|
1776
|
+
"""Inspect pending generations without writing or guessing consumption.
|
|
1777
|
+
|
|
1778
|
+
Legacy generation-free markers retain their timestamp-based inspection.
|
|
1779
|
+
"""
|
|
1780
|
+
check = f"markers[{label}]"
|
|
1781
|
+
git_dir = _git_dir(repo)
|
|
1782
|
+
if git_dir is None:
|
|
1783
|
+
return # cmd_doctor already reported this path as not a work tree
|
|
1784
|
+
markers = _read_markers(git_dir / MARKER_FILENAME)
|
|
1785
|
+
if not markers:
|
|
1786
|
+
findings.append((DOCTOR_OK, check, "no unconsumed markers"))
|
|
1787
|
+
return
|
|
1788
|
+
if any("generation" in marker for marker in markers):
|
|
1789
|
+
findings.append(
|
|
1790
|
+
(
|
|
1791
|
+
DOCTOR_INFO,
|
|
1792
|
+
check,
|
|
1793
|
+
f"{len(markers)} pending generation(s) — inspect the commit note before retrying; "
|
|
1794
|
+
"marker timestamps do not establish consumption",
|
|
1795
|
+
)
|
|
1796
|
+
)
|
|
1797
|
+
return
|
|
1798
|
+
head_at = _git(["log", "-1", "--format=%cI", "HEAD"], repo)
|
|
1799
|
+
if not head_at:
|
|
1800
|
+
findings.append(
|
|
1801
|
+
(DOCTOR_INFO, check, f"{len(markers)} marker(s), no commit yet")
|
|
1802
|
+
)
|
|
1803
|
+
return
|
|
1804
|
+
# Compare instants, never the strings. Markers are stamped in UTC and git
|
|
1805
|
+
# reports %cI in the committer's local offset, so string ordering calls a
|
|
1806
|
+
# marker from 12:57+00:00 "older" than a commit at 20:47+09:00 — the same
|
|
1807
|
+
# instant, two hours apart, and a false consumption failure on every
|
|
1808
|
+
# machine east of UTC.
|
|
1809
|
+
newest_at = _max_instant(str(m.get("stamped_at", "")) for m in markers)
|
|
1810
|
+
head_instant = _parse_instant(head_at)
|
|
1811
|
+
if newest_at is None or head_instant is None:
|
|
1812
|
+
findings.append(
|
|
1813
|
+
(DOCTOR_INFO, check, f"{len(markers)} marker(s) with no readable timestamp")
|
|
1814
|
+
)
|
|
1815
|
+
return
|
|
1816
|
+
newest = newest_at.isoformat(timespec="seconds")
|
|
1817
|
+
if newest_at < head_instant:
|
|
1818
|
+
findings.append(
|
|
1819
|
+
(
|
|
1820
|
+
DOCTOR_FAIL,
|
|
1821
|
+
check,
|
|
1822
|
+
f"{len(markers)} marker(s) last written {newest}, older than HEAD "
|
|
1823
|
+
f"({head_at}) — the commit did not consume them; check the "
|
|
1824
|
+
"post-commit hook",
|
|
1825
|
+
)
|
|
1826
|
+
)
|
|
1827
|
+
return
|
|
1828
|
+
findings.append(
|
|
1829
|
+
(
|
|
1830
|
+
DOCTOR_INFO,
|
|
1831
|
+
check,
|
|
1832
|
+
f"{len(markers)} marker(s) newer than HEAD — not yet committed",
|
|
1833
|
+
)
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def _doctor_pi_extension() -> Finding:
|
|
1838
|
+
"""The pi shim's registration finding.
|
|
1839
|
+
|
|
1840
|
+
pi is an opt-in second harness: a machine without it reports info, never
|
|
1841
|
+
FAIL (a doctor that goes red for normal states gets ignored). pi present
|
|
1842
|
+
but the shim unregistered is FAIL — those sessions are never marked.
|
|
1843
|
+
|
|
1844
|
+
An installed CLI cannot resolve the shim directory at all, so it
|
|
1845
|
+
has no path to compare and reports info rather than a verdict — same
|
|
1846
|
+
rule, applied to a registration this build cannot check.
|
|
1847
|
+
"""
|
|
1848
|
+
path = _pi_settings_path()
|
|
1849
|
+
if not path.parent.exists():
|
|
1850
|
+
return (DOCTOR_INFO, "pi extension", "pi not detected (no ~/.pi/agent)")
|
|
1851
|
+
config = _load_json(path)
|
|
1852
|
+
if config is None:
|
|
1853
|
+
return (
|
|
1854
|
+
DOCTOR_FAIL,
|
|
1855
|
+
"pi extension",
|
|
1856
|
+
f"{path} is not valid JSON — install refuses it",
|
|
1857
|
+
)
|
|
1858
|
+
shim = _pi_extension_dir()
|
|
1859
|
+
if shim is None:
|
|
1860
|
+
# Absent, never guessed at: str(None) yielded the literal "None",
|
|
1861
|
+
# which matches no real path, so a correctly registered machine
|
|
1862
|
+
# reported FAIL and doctor exited 1.
|
|
1863
|
+
return (
|
|
1864
|
+
DOCTOR_INFO,
|
|
1865
|
+
"pi extension",
|
|
1866
|
+
"not checkable from an installed CLI (shims are checkout-only)",
|
|
1867
|
+
)
|
|
1868
|
+
extensions = config.get("extensions")
|
|
1869
|
+
if isinstance(extensions, list) and str(shim) in extensions:
|
|
1870
|
+
return (DOCTOR_OK, "pi extension", f"registered in {path}")
|
|
1871
|
+
return (
|
|
1872
|
+
DOCTOR_FAIL,
|
|
1873
|
+
"pi extension",
|
|
1874
|
+
f"not registered in {path} — pi sessions are never marked; run install",
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def _doctor_cursor_hooks() -> Finding:
|
|
1879
|
+
path = _cursor_hooks_path()
|
|
1880
|
+
if not path.parent.exists():
|
|
1881
|
+
return (DOCTOR_INFO, "cursor hooks", "Cursor not detected (no ~/.cursor)")
|
|
1882
|
+
config = _load_json(path)
|
|
1883
|
+
if config is None:
|
|
1884
|
+
return (
|
|
1885
|
+
DOCTOR_FAIL,
|
|
1886
|
+
"cursor hooks",
|
|
1887
|
+
f"{path} is not valid JSON — install refuses it",
|
|
1888
|
+
)
|
|
1889
|
+
version = config.get("version", 1)
|
|
1890
|
+
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
|
|
1891
|
+
return (
|
|
1892
|
+
DOCTOR_FAIL,
|
|
1893
|
+
"cursor hooks",
|
|
1894
|
+
f"{path} has unsupported Cursor hook version {version!r}",
|
|
1895
|
+
)
|
|
1896
|
+
hooks = config.get("hooks", {})
|
|
1897
|
+
if not isinstance(hooks, dict):
|
|
1898
|
+
return (DOCTOR_FAIL, "cursor hooks", f"{path} has a non-object 'hooks' key")
|
|
1899
|
+
for event, matcher in _CURSOR_HOOKS.items():
|
|
1900
|
+
entries = hooks.get(event, [])
|
|
1901
|
+
if not isinstance(entries, list):
|
|
1902
|
+
return (DOCTOR_FAIL, "cursor hooks", f"{path} has a non-list {event}")
|
|
1903
|
+
entry_error = _cursor_hook_entries_error(entries)
|
|
1904
|
+
if entry_error is not None:
|
|
1905
|
+
return (
|
|
1906
|
+
DOCTOR_FAIL,
|
|
1907
|
+
"cursor hooks",
|
|
1908
|
+
f"{path} has invalid {event} entry: {entry_error}",
|
|
1909
|
+
)
|
|
1910
|
+
ours = [entry for entry in entries if _is_cursor_hook(entry)]
|
|
1911
|
+
if len(ours) != 1:
|
|
1912
|
+
return (
|
|
1913
|
+
DOCTOR_FAIL,
|
|
1914
|
+
"cursor hooks",
|
|
1915
|
+
f"{event} has {len(ours)} Sediment entries; run install",
|
|
1916
|
+
)
|
|
1917
|
+
script = _referenced_script(ours[0]["command"])
|
|
1918
|
+
if script is not None and not script.exists():
|
|
1919
|
+
return (
|
|
1920
|
+
DOCTOR_FAIL,
|
|
1921
|
+
"cursor hooks",
|
|
1922
|
+
f"{path} invokes {script}, which does not exist — re-run install",
|
|
1923
|
+
)
|
|
1924
|
+
desired = _cursor_hook_entry(_script_invocation("cursor-hook"), matcher)
|
|
1925
|
+
if ours[0].get("command") != desired["command"]:
|
|
1926
|
+
return (
|
|
1927
|
+
DOCTOR_FAIL,
|
|
1928
|
+
"cursor hooks",
|
|
1929
|
+
f"{event} has a stale command; run install",
|
|
1930
|
+
)
|
|
1931
|
+
if ours[0] != desired:
|
|
1932
|
+
return (
|
|
1933
|
+
DOCTOR_FAIL,
|
|
1934
|
+
"cursor hooks",
|
|
1935
|
+
f"{event} has a stale matcher or shape; run install",
|
|
1936
|
+
)
|
|
1937
|
+
return (DOCTOR_OK, "cursor hooks", f"present in {path}")
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def cmd_doctor(
|
|
1941
|
+
repos: list[str],
|
|
1942
|
+
fetch: bool,
|
|
1943
|
+
agent: str | None = None,
|
|
1944
|
+
session_id: str | None = None,
|
|
1945
|
+
transcripts: bool = False,
|
|
1946
|
+
inference_calls: bool = False,
|
|
1947
|
+
) -> int:
|
|
1948
|
+
"""One-shot client-side health check. Exits 1 when any check FAILs.
|
|
1949
|
+
|
|
1950
|
+
Exit codes: 0 every check passed (``info`` findings do not count), 1 at
|
|
1951
|
+
least one FAIL, 2 bad arguments (argparse). Suitable for an
|
|
1952
|
+
MDM-scheduled script: one line per finding on stdout, verdict in the
|
|
1953
|
+
exit code.
|
|
1954
|
+
"""
|
|
1955
|
+
findings: list[Finding] = []
|
|
1956
|
+
for harness, paths in (
|
|
1957
|
+
("claude-code", _claude_settings_candidates()),
|
|
1958
|
+
("codex", [_codex_hooks_path()]),
|
|
1959
|
+
):
|
|
1960
|
+
if agent is not None and agent != harness:
|
|
1961
|
+
continue
|
|
1962
|
+
findings.append(
|
|
1963
|
+
_doctor_agent_hook(f"{harness} hook", paths, f"mark --tool {harness}")
|
|
1964
|
+
)
|
|
1965
|
+
findings.append(
|
|
1966
|
+
_doctor_agent_hook(
|
|
1967
|
+
f"{harness} transcript hook",
|
|
1968
|
+
paths,
|
|
1969
|
+
f" transcript --agent {harness}",
|
|
1970
|
+
event="SessionEnd",
|
|
1971
|
+
required=transcripts,
|
|
1972
|
+
)
|
|
1973
|
+
)
|
|
1974
|
+
if harness == "claude-code":
|
|
1975
|
+
findings.append(
|
|
1976
|
+
_doctor_agent_hook(
|
|
1977
|
+
"claude-code snapshot hook",
|
|
1978
|
+
paths,
|
|
1979
|
+
" transcript snapshot --agent claude-code",
|
|
1980
|
+
event="PreToolUse",
|
|
1981
|
+
required=False,
|
|
1982
|
+
)
|
|
1983
|
+
)
|
|
1984
|
+
if agent in (None, "cursor"):
|
|
1985
|
+
findings.append(_doctor_cursor_hooks())
|
|
1986
|
+
if agent in (None, "pi"):
|
|
1987
|
+
findings.append(_doctor_pi_extension())
|
|
1988
|
+
if agent is not None:
|
|
1989
|
+
required_checks = {f"{agent} hook", "cursor hooks", "pi extension"}
|
|
1990
|
+
if transcripts:
|
|
1991
|
+
required_checks.add(f"{agent} transcript hook")
|
|
1992
|
+
findings = [
|
|
1993
|
+
(
|
|
1994
|
+
DOCTOR_FAIL
|
|
1995
|
+
if status == DOCTOR_INFO and check in required_checks
|
|
1996
|
+
else status,
|
|
1997
|
+
check,
|
|
1998
|
+
detail,
|
|
1999
|
+
)
|
|
2000
|
+
for status, check, detail in findings
|
|
2001
|
+
]
|
|
2002
|
+
if agent == "pi" and transcripts:
|
|
2003
|
+
enabled = os.environ.get("SEDIMENT_PI_TRANSCRIPTS") == "1"
|
|
2004
|
+
findings.append(
|
|
2005
|
+
(
|
|
2006
|
+
DOCTOR_OK if enabled else DOCTOR_FAIL,
|
|
2007
|
+
"pi transcript opt-in",
|
|
2008
|
+
"enabled"
|
|
2009
|
+
if enabled
|
|
2010
|
+
else "set SEDIMENT_PI_TRANSCRIPTS=1 in the pi environment",
|
|
2011
|
+
)
|
|
2012
|
+
)
|
|
2013
|
+
_doctor_fleet(findings)
|
|
2014
|
+
_doctor_log(findings)
|
|
2015
|
+
if agent is None:
|
|
2016
|
+
_doctor_server(findings)
|
|
2017
|
+
findings.append(
|
|
2018
|
+
_doctor_capture_endpoint(required=agent in ("cursor", "pi") or transcripts)
|
|
2019
|
+
)
|
|
2020
|
+
findings.append(_doctor_delivery(os.environ.get("SEDIMENT_DELIVERY_DIR")))
|
|
2021
|
+
for repo in repos:
|
|
2022
|
+
path = Path(repo).resolve()
|
|
2023
|
+
label = str(path)
|
|
2024
|
+
if _git_dir(path) is None:
|
|
2025
|
+
findings.append((DOCTOR_FAIL, f"repo[{label}]", "not a git work tree"))
|
|
2026
|
+
continue
|
|
2027
|
+
_doctor_hooks(findings, label, path)
|
|
2028
|
+
_doctor_rewrite_ref(findings, label, path)
|
|
2029
|
+
_doctor_notes_state(findings, label, path, fetch)
|
|
2030
|
+
_doctor_markers(findings, label, path)
|
|
2031
|
+
if agent is not None and session_id is not None:
|
|
2032
|
+
_doctor_session_evidence(
|
|
2033
|
+
findings, path, agent, session_id, transcripts, inference_calls
|
|
2034
|
+
)
|
|
2035
|
+
width = max(len(status) for status, _, _ in findings)
|
|
2036
|
+
for status, check, detail in findings:
|
|
2037
|
+
# info stays neutral — phosphor is for passing checks only.
|
|
2038
|
+
color = {DOCTOR_FAIL: "iron-oxide", DOCTOR_OK: "phosphor"}.get(status, "dim")
|
|
2039
|
+
print(f"{ui.style(f'{status:<{width}}', color)} {check}: {detail}")
|
|
2040
|
+
failures = sum(1 for status, _, _ in findings if status == DOCTOR_FAIL)
|
|
2041
|
+
if failures:
|
|
2042
|
+
# Flush first: the two streams are separately buffered, so without
|
|
2043
|
+
# this the verdict lands above the findings it summarizes whenever
|
|
2044
|
+
# stdout is a pipe (the MDM-script case).
|
|
2045
|
+
sys.stdout.flush()
|
|
2046
|
+
verdict = ui.style(
|
|
2047
|
+
f"{failures} check(s) failed.", "iron-oxide", stream=sys.stderr
|
|
2048
|
+
)
|
|
2049
|
+
print(f"\n{verdict}", file=sys.stderr)
|
|
2050
|
+
return 1 if failures else 0
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _sediment_executable() -> str | None:
|
|
2054
|
+
"""The selected ``sediment`` command, or None on a bare checkout.
|
|
2055
|
+
|
|
2056
|
+
Resolved at INSTALL time and embedded absolute: hook runtime PATH (a
|
|
2057
|
+
Dock-launched agent, a bare git env) cannot be trusted to contain the
|
|
2058
|
+
user's tool bin dir. Honor an explicitly invoked CLI, including a retained
|
|
2059
|
+
source checkout. Bare scripts skip unrelated virtualenv shims on PATH.
|
|
2060
|
+
# ponytail: ".venv" name test covers uv's convention; a custom-named
|
|
2061
|
+
# venv would need sys.prefix probing — add it when someone hits it.
|
|
2062
|
+
"""
|
|
2063
|
+
invoked = Path(sys.argv[0]).absolute()
|
|
2064
|
+
if invoked.name == "sediment" and invoked.is_file() and os.access(invoked, os.X_OK):
|
|
2065
|
+
return str(invoked)
|
|
2066
|
+
for d in os.get_exec_path():
|
|
2067
|
+
cand = Path(d) / "sediment"
|
|
2068
|
+
if ".venv" in cand.parts:
|
|
2069
|
+
continue
|
|
2070
|
+
if cand.is_file() and os.access(cand, os.X_OK):
|
|
2071
|
+
return str(cand)
|
|
2072
|
+
return None
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _repo_root_file(*relpath: str) -> Path | None:
|
|
2076
|
+
"""Resolve a checkout file by walking up from this module. Returns None
|
|
2077
|
+
when installed from a wheel, where checkout-only assets are absent."""
|
|
2078
|
+
here = Path(__file__).resolve()
|
|
2079
|
+
for parent in here.parents[:5]:
|
|
2080
|
+
cand = parent.joinpath(*relpath)
|
|
2081
|
+
if cand.exists():
|
|
2082
|
+
return cand
|
|
2083
|
+
return None
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _script_invocation(subcommand: str) -> str:
|
|
2087
|
+
# Prefer the selected `sediment` command. Source installations must retain
|
|
2088
|
+
# their checkout while its hooks are enrolled. Fallback for bare scripts:
|
|
2089
|
+
# this file via
|
|
2090
|
+
# sys.executable, not a PATH-dependent `python3` — hooks run in whatever
|
|
2091
|
+
# environment git/the agent provides, where python3 may be absent.
|
|
2092
|
+
exe = _sediment_executable()
|
|
2093
|
+
if exe:
|
|
2094
|
+
return f'"{exe}" {subcommand} || true'
|
|
2095
|
+
script = Path(__file__).resolve()
|
|
2096
|
+
return f'"{sys.executable}" "{script}" {subcommand} || true'
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _hooks_dir(repo: Path) -> Path | None:
|
|
2100
|
+
"""The hooks dir git will actually execute from, or None if unresolvable.
|
|
2101
|
+
|
|
2102
|
+
``--git-path hooks`` is authoritative: it applies core.hooksPath (including
|
|
2103
|
+
tilde expansion, which a naive config read misses) and resolves the COMMON
|
|
2104
|
+
git dir in linked worktrees — where ``--absolute-git-dir`` points at the
|
|
2105
|
+
per-worktree dir whose hooks/ git never runs.
|
|
2106
|
+
"""
|
|
2107
|
+
out = _git(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo)
|
|
2108
|
+
return Path(out) if out else None
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
# Interpreters whose scripts can safely receive an appended sh block. A
|
|
2112
|
+
# substring test would wrongly admit fish/pwsh (both contain "sh"), whose
|
|
2113
|
+
# syntax our block would break.
|
|
2114
|
+
_POSIX_SHELLS = frozenset({"sh", "bash", "dash", "ash", "ksh", "zsh"})
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _sh_compatible(content: str) -> bool:
|
|
2118
|
+
"""True if a hook script can safely receive an appended sh block."""
|
|
2119
|
+
first = content.splitlines()[0] if content else ""
|
|
2120
|
+
if not first.startswith("#!"):
|
|
2121
|
+
return True # git runs shebang-less hooks through sh
|
|
2122
|
+
parts = first[2:].strip().split()
|
|
2123
|
+
if not parts:
|
|
2124
|
+
return False
|
|
2125
|
+
interpreter = Path(parts[0]).name
|
|
2126
|
+
if interpreter == "env":
|
|
2127
|
+
rest = [p for p in parts[1:] if not p.startswith("-")] # skip env flags
|
|
2128
|
+
interpreter = Path(rest[0]).name if rest else ""
|
|
2129
|
+
return interpreter in _POSIX_SHELLS
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def _install_hook_block(hook_file: Path, command: str) -> bool:
|
|
2133
|
+
"""Insert/replace our marked block in a hook script, preserving the rest.
|
|
2134
|
+
|
|
2135
|
+
Refuses (returns False) when the existing hook is not an sh script —
|
|
2136
|
+
appending sh to a python/binary hook would break every commit/push, the
|
|
2137
|
+
one thing the stamper must never do.
|
|
2138
|
+
"""
|
|
2139
|
+
block = f"{HOOK_BLOCK_BEGIN}\n{command}\n{HOOK_BLOCK_END}\n"
|
|
2140
|
+
if hook_file.exists():
|
|
2141
|
+
try:
|
|
2142
|
+
content = hook_file.read_text(encoding="utf-8")
|
|
2143
|
+
except (OSError, UnicodeDecodeError):
|
|
2144
|
+
_warn(
|
|
2145
|
+
f"{hook_file} is not a text hook script; "
|
|
2146
|
+
"not modified — install this hook manually"
|
|
2147
|
+
)
|
|
2148
|
+
return False
|
|
2149
|
+
if not _sh_compatible(content):
|
|
2150
|
+
_warn(
|
|
2151
|
+
f"{hook_file} is not an sh script; "
|
|
2152
|
+
"not modified — install this hook manually"
|
|
2153
|
+
)
|
|
2154
|
+
return False
|
|
2155
|
+
if _HOOK_BLOCK_RE.search(content):
|
|
2156
|
+
# Replace via a function, not the string form: `block` embeds an
|
|
2157
|
+
# absolute script path, so a literal `\` or a `\g`/`\1` sequence in
|
|
2158
|
+
# it would be parsed as a replacement escape and raise re.error
|
|
2159
|
+
# (breaks the re-install path on Windows paths like C:\…).
|
|
2160
|
+
content = _HOOK_BLOCK_RE.sub(lambda _m: block, content)
|
|
2161
|
+
else:
|
|
2162
|
+
if content and not content.endswith("\n"):
|
|
2163
|
+
content += "\n"
|
|
2164
|
+
content += block
|
|
2165
|
+
else:
|
|
2166
|
+
content = f"#!/bin/sh\n{block}"
|
|
2167
|
+
hook_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2168
|
+
hook_file.write_text(content, encoding="utf-8")
|
|
2169
|
+
hook_file.chmod(hook_file.stat().st_mode | 0o755)
|
|
2170
|
+
return True
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def _remove_hook_block(hook_file: Path) -> None:
|
|
2174
|
+
if not hook_file.exists():
|
|
2175
|
+
return
|
|
2176
|
+
try:
|
|
2177
|
+
content = hook_file.read_text(encoding="utf-8")
|
|
2178
|
+
except (OSError, UnicodeDecodeError):
|
|
2179
|
+
return # not a text hook we ever wrote to
|
|
2180
|
+
hook_file.write_text(_HOOK_BLOCK_RE.sub("", content), encoding="utf-8")
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def _claude_settings_path() -> Path:
|
|
2184
|
+
return Path.home() / ".claude" / "settings.json"
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def _codex_hooks_path() -> Path:
|
|
2188
|
+
home = os.environ.get("CODEX_HOME")
|
|
2189
|
+
return (Path(home) if home else Path.home() / ".codex") / "hooks.json"
|
|
2190
|
+
|
|
2191
|
+
|
|
2192
|
+
def _cursor_hooks_path() -> Path:
|
|
2193
|
+
return Path.home() / ".cursor" / "hooks.json"
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _pi_settings_path() -> Path:
|
|
2197
|
+
return Path.home() / ".pi" / "agent" / "settings.json"
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _pi_extension_dir() -> Path | None:
|
|
2201
|
+
"""The pi shim (shims/pi/) — checkout-only: shims are not shipped
|
|
2202
|
+
in the sediment-cli wheel, so an installed CLI resolves None and the pi
|
|
2203
|
+
registration skips with a message."""
|
|
2204
|
+
return _repo_root_file("shims", "pi")
|
|
2205
|
+
|
|
2206
|
+
|
|
2207
|
+
def _load_json(path: Path) -> dict | None:
|
|
2208
|
+
"""Load an agent config: {} for a missing file, None for one we must not
|
|
2209
|
+
touch (unreadable, invalid JSON, or non-object top level).
|
|
2210
|
+
|
|
2211
|
+
The None path is load-bearing: writing back a default over a file that
|
|
2212
|
+
merely failed to parse would destroy the user's settings.
|
|
2213
|
+
"""
|
|
2214
|
+
if not path.exists():
|
|
2215
|
+
return {}
|
|
2216
|
+
try:
|
|
2217
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
2218
|
+
except (OSError, ValueError):
|
|
2219
|
+
return None
|
|
2220
|
+
return data if isinstance(data, dict) else None
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _write_json_atomic(path: Path, data: dict) -> None:
|
|
2224
|
+
"""Write via temp file + rename so a crash can't truncate the config."""
|
|
2225
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2226
|
+
tmp = path.with_name(path.name + ".sediment-tmp")
|
|
2227
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
2228
|
+
os.replace(tmp, path)
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def _is_our_hook(hook: dict) -> bool:
|
|
2232
|
+
"""True if this hook entry is one we installed (stamper or transcript
|
|
2233
|
+
extractor). Tolerates a non-string ``command`` (a malformed-but-parseable
|
|
2234
|
+
config): ``in`` against a non-str would raise TypeError and crash
|
|
2235
|
+
install/uninstall — the best-effort installer must never do that."""
|
|
2236
|
+
command = hook.get("command", "")
|
|
2237
|
+
return isinstance(command, str) and _is_sediment_command(command)
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
def _is_cursor_hook(hook: object) -> bool:
|
|
2241
|
+
if not isinstance(hook, dict):
|
|
2242
|
+
return False
|
|
2243
|
+
command = hook.get("command")
|
|
2244
|
+
return (
|
|
2245
|
+
isinstance(command, str)
|
|
2246
|
+
and "cursor-hook" in command
|
|
2247
|
+
and _is_sediment_command(command)
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
def _has_our_entry(blocks: list) -> bool:
|
|
2252
|
+
return any(_is_our_hook(hook) for hook in _iter_hooks(blocks))
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
def _install_agent_entry(
|
|
2256
|
+
path: Path, block: dict, label: str, event: str = "PostToolUse"
|
|
2257
|
+
) -> str:
|
|
2258
|
+
"""Add a hook block to an agent's JSON config; returns a status word.
|
|
2259
|
+
|
|
2260
|
+
Refuses to touch a config it can't fully parse ("skipped") — never
|
|
2261
|
+
rewrites a file it didn't understand.
|
|
2262
|
+
"""
|
|
2263
|
+
config = _load_json(path)
|
|
2264
|
+
if config is None:
|
|
2265
|
+
_warn(
|
|
2266
|
+
f"{path} exists but is not valid JSON; {label} hook NOT "
|
|
2267
|
+
"installed — fix the file and re-run install"
|
|
2268
|
+
)
|
|
2269
|
+
return "skipped"
|
|
2270
|
+
hooks = config.setdefault("hooks", {})
|
|
2271
|
+
if not isinstance(hooks, dict):
|
|
2272
|
+
_warn(
|
|
2273
|
+
f"{path} has a non-object 'hooks' key; {label} hook NOT "
|
|
2274
|
+
"installed — fix the file and re-run install"
|
|
2275
|
+
)
|
|
2276
|
+
return "skipped"
|
|
2277
|
+
blocks = hooks.setdefault(event, [])
|
|
2278
|
+
if not isinstance(blocks, list):
|
|
2279
|
+
_warn(
|
|
2280
|
+
f"{path} has a non-list {event}; {label} hook NOT "
|
|
2281
|
+
"installed — fix the file and re-run install"
|
|
2282
|
+
)
|
|
2283
|
+
return "skipped"
|
|
2284
|
+
ours = [hook for hook in _iter_hooks(blocks) if _is_our_hook(hook)]
|
|
2285
|
+
if ours:
|
|
2286
|
+
# An existing entry may point at an old script path (moved checkout,
|
|
2287
|
+
# fleet prefix migration). "Already present" would leave a stale
|
|
2288
|
+
# command that silently no-ops behind `|| true` — update it in place.
|
|
2289
|
+
desired = block["hooks"][0]["command"]
|
|
2290
|
+
if all(hook.get("command") == desired for hook in ours):
|
|
2291
|
+
return "already present"
|
|
2292
|
+
for hook in ours:
|
|
2293
|
+
hook["command"] = desired
|
|
2294
|
+
_write_json_atomic(path, config)
|
|
2295
|
+
return "updated"
|
|
2296
|
+
blocks.append(block)
|
|
2297
|
+
_write_json_atomic(path, config)
|
|
2298
|
+
return "added"
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def _claude_hook_block(command: str) -> dict:
|
|
2302
|
+
"""The Claude Code PostToolUse entry — the one place CLAUDE_MATCHER is
|
|
2303
|
+
wired into a fragment (per-user install and the fleet bundle both build
|
|
2304
|
+
from here, so the doc'd MDM fragment can never drift from the installer)."""
|
|
2305
|
+
return {
|
|
2306
|
+
"matcher": CLAUDE_MATCHER,
|
|
2307
|
+
"hooks": [{"type": "command", "command": command}],
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def _codex_hook_block(command: str) -> dict:
|
|
2312
|
+
return {"hooks": [{"type": "command", "command": command}]}
|
|
2313
|
+
|
|
2314
|
+
|
|
2315
|
+
def _install_claude_hook() -> str:
|
|
2316
|
+
if not _claude_settings_path().parent.exists():
|
|
2317
|
+
return _skipped(
|
|
2318
|
+
"claude-code hook: skipped (Claude Code not detected — "
|
|
2319
|
+
f"{_claude_settings_path().parent} does not exist)"
|
|
2320
|
+
)
|
|
2321
|
+
return _install_agent_entry(
|
|
2322
|
+
_claude_settings_path(),
|
|
2323
|
+
_claude_hook_block(_script_invocation("mark --tool claude-code")),
|
|
2324
|
+
"claude-code",
|
|
2325
|
+
)
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def _install_codex_hook() -> str:
|
|
2329
|
+
if not _codex_hooks_path().parent.exists():
|
|
2330
|
+
return _skipped(
|
|
2331
|
+
"codex hook: skipped (Codex not detected — "
|
|
2332
|
+
f"{_codex_hooks_path().parent} does not exist)"
|
|
2333
|
+
)
|
|
2334
|
+
return _install_agent_entry(
|
|
2335
|
+
_codex_hooks_path(),
|
|
2336
|
+
_codex_hook_block(_script_invocation("mark --tool codex")),
|
|
2337
|
+
"codex",
|
|
2338
|
+
)
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def _cursor_hook_entry(command: str, matcher: str | None) -> dict:
|
|
2342
|
+
entry = {"command": command}
|
|
2343
|
+
if matcher is not None:
|
|
2344
|
+
entry["matcher"] = matcher
|
|
2345
|
+
return entry
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
def _cursor_hook_entries_error(entries: list[object]) -> str | None:
|
|
2349
|
+
for index, entry in enumerate(entries):
|
|
2350
|
+
if not isinstance(entry, dict):
|
|
2351
|
+
return f"entry {index} is not an object"
|
|
2352
|
+
if not isinstance(entry.get("command"), str):
|
|
2353
|
+
return f"entry {index} has a non-string command"
|
|
2354
|
+
if "matcher" in entry and not isinstance(entry["matcher"], str):
|
|
2355
|
+
return f"entry {index} has a non-string matcher"
|
|
2356
|
+
return None
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
def _install_cursor_hooks() -> str:
|
|
2360
|
+
path = _cursor_hooks_path()
|
|
2361
|
+
if not path.parent.exists():
|
|
2362
|
+
return _skipped(
|
|
2363
|
+
"cursor hooks: skipped (Cursor not detected — ~/.cursor does not exist)"
|
|
2364
|
+
)
|
|
2365
|
+
config = _load_json(path)
|
|
2366
|
+
if config is None:
|
|
2367
|
+
_warn(
|
|
2368
|
+
f"{path} exists but is not valid JSON; cursor hooks NOT "
|
|
2369
|
+
"installed — fix the file and re-run install"
|
|
2370
|
+
)
|
|
2371
|
+
return "skipped"
|
|
2372
|
+
has_version = "version" in config
|
|
2373
|
+
version = config.get("version")
|
|
2374
|
+
if has_version and (
|
|
2375
|
+
not isinstance(version, int) or isinstance(version, bool) or version != 1
|
|
2376
|
+
):
|
|
2377
|
+
_warn(
|
|
2378
|
+
f"{path} has unsupported Cursor hook version {version!r}; cursor hooks "
|
|
2379
|
+
"NOT installed"
|
|
2380
|
+
)
|
|
2381
|
+
return "skipped"
|
|
2382
|
+
hooks = config.get("hooks", {})
|
|
2383
|
+
if not isinstance(hooks, dict):
|
|
2384
|
+
_warn(
|
|
2385
|
+
f"{path} has a non-object 'hooks' key; cursor hooks NOT installed — "
|
|
2386
|
+
"fix the file and re-run install"
|
|
2387
|
+
)
|
|
2388
|
+
return "skipped"
|
|
2389
|
+
for event in _CURSOR_HOOKS:
|
|
2390
|
+
entries = hooks.get(event, [])
|
|
2391
|
+
if not isinstance(entries, list):
|
|
2392
|
+
_warn(
|
|
2393
|
+
f"{path} has a non-list {event}; cursor hooks NOT installed — "
|
|
2394
|
+
"fix the file and re-run install"
|
|
2395
|
+
)
|
|
2396
|
+
return "skipped"
|
|
2397
|
+
entry_error = _cursor_hook_entries_error(entries)
|
|
2398
|
+
if entry_error is not None:
|
|
2399
|
+
_warn(
|
|
2400
|
+
f"{path} has invalid {event} entry: {entry_error}; cursor hooks "
|
|
2401
|
+
"NOT installed — fix the file and re-run install"
|
|
2402
|
+
)
|
|
2403
|
+
return "skipped"
|
|
2404
|
+
|
|
2405
|
+
command = _script_invocation("cursor-hook")
|
|
2406
|
+
changed = not has_version
|
|
2407
|
+
found_existing = False
|
|
2408
|
+
for event, matcher in _CURSOR_HOOKS.items():
|
|
2409
|
+
entries = hooks.get(event, [])
|
|
2410
|
+
desired = _cursor_hook_entry(command, matcher)
|
|
2411
|
+
ours = [entry for entry in entries if _is_cursor_hook(entry)]
|
|
2412
|
+
found_existing = found_existing or bool(ours)
|
|
2413
|
+
if len(ours) == 1 and ours[0] == desired:
|
|
2414
|
+
continue
|
|
2415
|
+
hooks[event] = [entry for entry in entries if not _is_cursor_hook(entry)]
|
|
2416
|
+
hooks[event].append(desired)
|
|
2417
|
+
changed = True
|
|
2418
|
+
if not changed:
|
|
2419
|
+
return "already present"
|
|
2420
|
+
config["version"] = 1
|
|
2421
|
+
config["hooks"] = hooks
|
|
2422
|
+
try:
|
|
2423
|
+
_write_json_atomic(path, config)
|
|
2424
|
+
except OSError as exc:
|
|
2425
|
+
_warn(
|
|
2426
|
+
f"{path} could not be written ({type(exc).__name__}); cursor hooks "
|
|
2427
|
+
"NOT installed"
|
|
2428
|
+
)
|
|
2429
|
+
return "skipped"
|
|
2430
|
+
return "updated" if found_existing else "added"
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
def _install_pi_extension() -> str:
|
|
2434
|
+
"""Register the shim in pi's ``settings.json`` extensions list.
|
|
2435
|
+
|
|
2436
|
+
pi auto-discovers extension dirs listed there; no hook fragment needed —
|
|
2437
|
+
the shim invokes ``mark --tool pi`` itself. Same safety posture as the
|
|
2438
|
+
hook installers: a settings file we cannot fully parse is refused,
|
|
2439
|
+
never rewritten.
|
|
2440
|
+
|
|
2441
|
+
Gated on pi's config directory existing — the same presence check
|
|
2442
|
+
``_doctor_pi_extension`` uses. Absent → skipped, no file written.
|
|
2443
|
+
"""
|
|
2444
|
+
path = _pi_settings_path()
|
|
2445
|
+
if not path.parent.exists():
|
|
2446
|
+
return _skipped(
|
|
2447
|
+
"pi extension: skipped (pi not detected — ~/.pi/agent does not exist)"
|
|
2448
|
+
)
|
|
2449
|
+
if _pi_extension_dir() is None:
|
|
2450
|
+
return _skipped(
|
|
2451
|
+
"pi extension: skipped (shims/pi not present — checkout-only; "
|
|
2452
|
+
"run install from a sediment checkout to register it)"
|
|
2453
|
+
)
|
|
2454
|
+
config = _load_json(path)
|
|
2455
|
+
if config is None:
|
|
2456
|
+
_warn(
|
|
2457
|
+
f"{path} exists but is not valid JSON; pi extension NOT "
|
|
2458
|
+
"installed — fix the file and re-run install"
|
|
2459
|
+
)
|
|
2460
|
+
return "skipped"
|
|
2461
|
+
extensions = config.setdefault("extensions", [])
|
|
2462
|
+
if not isinstance(extensions, list):
|
|
2463
|
+
_warn(
|
|
2464
|
+
f"{path} has a non-list 'extensions' key; pi extension "
|
|
2465
|
+
"NOT installed — fix the file and re-run install"
|
|
2466
|
+
)
|
|
2467
|
+
return "skipped"
|
|
2468
|
+
entry = str(_pi_extension_dir())
|
|
2469
|
+
if entry in extensions:
|
|
2470
|
+
return "already present"
|
|
2471
|
+
extensions.append(entry)
|
|
2472
|
+
_write_json_atomic(path, config)
|
|
2473
|
+
return "added"
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
def _remove_pi_extension() -> bool:
|
|
2477
|
+
"""Drop our entry from pi's extensions list; foreign entries are kept."""
|
|
2478
|
+
path = _pi_settings_path()
|
|
2479
|
+
config = _load_json(path)
|
|
2480
|
+
if config is None:
|
|
2481
|
+
return False
|
|
2482
|
+
extensions = config.get("extensions")
|
|
2483
|
+
if not isinstance(extensions, list):
|
|
2484
|
+
return False
|
|
2485
|
+
entry = str(_pi_extension_dir())
|
|
2486
|
+
if entry not in extensions:
|
|
2487
|
+
return False
|
|
2488
|
+
extensions.remove(entry)
|
|
2489
|
+
_write_json_atomic(path, config)
|
|
2490
|
+
return True
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
def _transcript_invocation(subcommand: str = "", *, agent: str = "claude-code") -> str:
|
|
2494
|
+
argument = f" {subcommand}" if subcommand else ""
|
|
2495
|
+
executable = _sediment_executable()
|
|
2496
|
+
if executable is not None:
|
|
2497
|
+
return f'"{executable}" transcript{argument} --agent {agent} || true'
|
|
2498
|
+
script = _capture_client_path("transcript").resolve()
|
|
2499
|
+
return f'"{sys.executable}" "{script}"{argument} --agent {agent} || true'
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
def _install_transcript_hook() -> str:
|
|
2503
|
+
"""Opt-in (ADR 0007): the SessionEnd extractor ships edit text pairs,
|
|
2504
|
+
so it is never installed implicitly with the stamper's own hooks."""
|
|
2505
|
+
invocation = _transcript_invocation()
|
|
2506
|
+
return _install_agent_entry(
|
|
2507
|
+
_claude_settings_path(),
|
|
2508
|
+
{"hooks": [{"type": "command", "command": invocation}]},
|
|
2509
|
+
"claude-code transcript",
|
|
2510
|
+
event="SessionEnd",
|
|
2511
|
+
)
|
|
2512
|
+
|
|
2513
|
+
|
|
2514
|
+
def _install_codex_transcript_hook() -> str:
|
|
2515
|
+
"""Opt-in Codex SessionEnd extractor for structured patch events."""
|
|
2516
|
+
path = _codex_hooks_path()
|
|
2517
|
+
if not path.parent.exists():
|
|
2518
|
+
return _skipped(
|
|
2519
|
+
"codex SessionEnd hook: skipped (Codex not detected — "
|
|
2520
|
+
f"{path.parent} does not exist)"
|
|
2521
|
+
)
|
|
2522
|
+
invocation = _transcript_invocation(agent="codex")
|
|
2523
|
+
return _install_agent_entry(
|
|
2524
|
+
path,
|
|
2525
|
+
{"hooks": [{"type": "command", "command": invocation}]},
|
|
2526
|
+
"codex transcript",
|
|
2527
|
+
event="SessionEnd",
|
|
2528
|
+
)
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
# Only the edit tools: the snapshot hook reads the file each call is about to
|
|
2532
|
+
# write, so Bash and the read-only tools have nothing for it to do.
|
|
2533
|
+
CLAUDE_EDIT_MATCHER = "Edit|Write"
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
def _install_snapshot_hook() -> str:
|
|
2537
|
+
"""The external-delta snapshots, same ``--transcripts`` opt-in.
|
|
2538
|
+
|
|
2539
|
+
Bundled with the SessionEnd extractor rather than flagged separately:
|
|
2540
|
+
the counts are only ever shipped by that extractor, so installing one
|
|
2541
|
+
without the other either produces a cache nothing reads or a session-end
|
|
2542
|
+
pass with no windows to report.
|
|
2543
|
+
"""
|
|
2544
|
+
invocation = _transcript_invocation("snapshot")
|
|
2545
|
+
return _install_agent_entry(
|
|
2546
|
+
_claude_settings_path(),
|
|
2547
|
+
{
|
|
2548
|
+
"matcher": CLAUDE_EDIT_MATCHER,
|
|
2549
|
+
"hooks": [{"type": "command", "command": invocation}],
|
|
2550
|
+
},
|
|
2551
|
+
"claude-code snapshot",
|
|
2552
|
+
event="PreToolUse",
|
|
2553
|
+
)
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
def _remove_agent_entries(path: Path) -> bool:
|
|
2557
|
+
"""Remove OUR hook entries only; co-resident foreign hooks are kept."""
|
|
2558
|
+
config = _load_json(path)
|
|
2559
|
+
if config is None:
|
|
2560
|
+
return False
|
|
2561
|
+
hooks = config.get("hooks")
|
|
2562
|
+
if not isinstance(hooks, dict):
|
|
2563
|
+
return False
|
|
2564
|
+
changed = False
|
|
2565
|
+
for event, blocks in list(hooks.items()):
|
|
2566
|
+
if not isinstance(blocks, list):
|
|
2567
|
+
continue
|
|
2568
|
+
kept_blocks = []
|
|
2569
|
+
for block in blocks:
|
|
2570
|
+
if not isinstance(block, dict) or not _has_our_entry([block]):
|
|
2571
|
+
kept_blocks.append(block)
|
|
2572
|
+
continue
|
|
2573
|
+
# Drop only our entries; keep any foreign hooks sharing the block.
|
|
2574
|
+
foreign = [
|
|
2575
|
+
h
|
|
2576
|
+
for h in block.get("hooks", [])
|
|
2577
|
+
if not (isinstance(h, dict) and _is_our_hook(h))
|
|
2578
|
+
]
|
|
2579
|
+
changed = True
|
|
2580
|
+
if foreign:
|
|
2581
|
+
kept_blocks.append({**block, "hooks": foreign})
|
|
2582
|
+
hooks[event] = kept_blocks
|
|
2583
|
+
if changed:
|
|
2584
|
+
_write_json_atomic(path, config)
|
|
2585
|
+
return changed
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
def _remove_cursor_entries() -> bool:
|
|
2589
|
+
path = _cursor_hooks_path()
|
|
2590
|
+
config = _load_json(path)
|
|
2591
|
+
if config is None or config.get("version", 1) != 1:
|
|
2592
|
+
return False
|
|
2593
|
+
hooks = config.get("hooks")
|
|
2594
|
+
if not isinstance(hooks, dict):
|
|
2595
|
+
return False
|
|
2596
|
+
changed = False
|
|
2597
|
+
for event in _CURSOR_HOOKS:
|
|
2598
|
+
entries = hooks.get(event)
|
|
2599
|
+
if not isinstance(entries, list):
|
|
2600
|
+
continue
|
|
2601
|
+
kept = [entry for entry in entries if not _is_cursor_hook(entry)]
|
|
2602
|
+
if len(kept) != len(entries):
|
|
2603
|
+
hooks[event] = kept
|
|
2604
|
+
changed = True
|
|
2605
|
+
if changed:
|
|
2606
|
+
try:
|
|
2607
|
+
_write_json_atomic(path, config)
|
|
2608
|
+
except OSError as exc:
|
|
2609
|
+
_warn(
|
|
2610
|
+
f"{path} could not be written ({type(exc).__name__}); cursor "
|
|
2611
|
+
"hook entries NOT removed"
|
|
2612
|
+
)
|
|
2613
|
+
return False
|
|
2614
|
+
return changed
|
|
2615
|
+
|
|
2616
|
+
|
|
2617
|
+
# ── agent env wiring ───────────────────────────────────────────────────────
|
|
2618
|
+
#
|
|
2619
|
+
# `sediment install` writes the telemetry env the runbook used to ask users
|
|
2620
|
+
# to copy by hand (docs/operate/deploy.md) — generated from the login
|
|
2621
|
+
# config, so the missing-OTEL_LOGS_EXPORTER class of copy-paste bug cannot
|
|
2622
|
+
# recur. Two files, both 0600 (they carry the bearer token):
|
|
2623
|
+
# a fish conf.d file (auto-loaded, no profile editing) and a POSIX env.sh
|
|
2624
|
+
# sourced from a marker-guarded block appended to existing profiles.
|
|
2625
|
+
|
|
2626
|
+
ENV_BLOCK_BEGIN = "# >>> sediment env >>>"
|
|
2627
|
+
ENV_BLOCK_END = "# <<< sediment env <<<"
|
|
2628
|
+
_PROFILE_NAMES = (".zprofile", ".bashrc", ".profile")
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
def _valid_server_host(host: str) -> bool:
|
|
2632
|
+
try:
|
|
2633
|
+
ipaddress.ip_address(host)
|
|
2634
|
+
except ValueError:
|
|
2635
|
+
candidate = host[:-1] if host.endswith(".") else host
|
|
2636
|
+
try:
|
|
2637
|
+
candidate = candidate.encode("idna").decode("ascii")
|
|
2638
|
+
except UnicodeError:
|
|
2639
|
+
return False
|
|
2640
|
+
if "." in candidate and all(
|
|
2641
|
+
character.isdigit() or character == "." for character in candidate
|
|
2642
|
+
):
|
|
2643
|
+
return False
|
|
2644
|
+
labels = candidate.split(".")
|
|
2645
|
+
return (
|
|
2646
|
+
bool(candidate)
|
|
2647
|
+
and len(candidate) <= 253
|
|
2648
|
+
and all(
|
|
2649
|
+
label
|
|
2650
|
+
and len(label) <= 63
|
|
2651
|
+
and label[0].isalnum()
|
|
2652
|
+
and label[-1].isalnum()
|
|
2653
|
+
and all(character.isalnum() or character == "-" for character in label)
|
|
2654
|
+
for label in labels
|
|
2655
|
+
)
|
|
2656
|
+
)
|
|
2657
|
+
return True
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
def _safe_server_url(url: str) -> bool:
|
|
2661
|
+
"""Validate saved config in this standalone stdlib-only fleet file.
|
|
2662
|
+
|
|
2663
|
+
Keep this boundary synchronized with ``client.validate_server_url``.
|
|
2664
|
+
"""
|
|
2665
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in url):
|
|
2666
|
+
return False
|
|
2667
|
+
try:
|
|
2668
|
+
parsed = urllib.parse.urlsplit(url.strip())
|
|
2669
|
+
host = parsed.hostname
|
|
2670
|
+
parsed.port
|
|
2671
|
+
except ValueError:
|
|
2672
|
+
return False
|
|
2673
|
+
if (
|
|
2674
|
+
parsed.scheme not in {"http", "https"}
|
|
2675
|
+
or not host
|
|
2676
|
+
or not _valid_server_host(host)
|
|
2677
|
+
or parsed.username is not None
|
|
2678
|
+
or parsed.password is not None
|
|
2679
|
+
or parsed.path not in {"", "/"}
|
|
2680
|
+
or parsed.query
|
|
2681
|
+
or parsed.fragment
|
|
2682
|
+
or parsed.netloc.endswith(":")
|
|
2683
|
+
):
|
|
2684
|
+
return False
|
|
2685
|
+
if parsed.scheme == "https" or host.lower() == "localhost":
|
|
2686
|
+
return True
|
|
2687
|
+
try:
|
|
2688
|
+
address = ipaddress.ip_address(host)
|
|
2689
|
+
except ValueError:
|
|
2690
|
+
return False
|
|
2691
|
+
return address.is_loopback and (
|
|
2692
|
+
isinstance(address, ipaddress.IPv4Address)
|
|
2693
|
+
or address == ipaddress.IPv6Address("::1")
|
|
2694
|
+
)
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
_CAPTURE_LOGIN = (
|
|
2698
|
+
"run sediment login <url> --capture --with-token to enroll an ingest credential"
|
|
2699
|
+
)
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
def _cli_config(*, capture: bool = False) -> tuple[str, str] | None:
|
|
2703
|
+
"""(url, token) for the current server from ``~/.sediment/config.json``
|
|
2704
|
+
(written by ``sediment login``) — parsed directly so this module
|
|
2705
|
+
stays stdlib-only. None when not logged in."""
|
|
2706
|
+
path = Path.home() / ".sediment" / "config.json"
|
|
2707
|
+
try:
|
|
2708
|
+
cfg = json.loads(path.read_text(encoding="utf-8"))
|
|
2709
|
+
except (OSError, ValueError):
|
|
2710
|
+
cfg = {}
|
|
2711
|
+
if not isinstance(cfg, dict):
|
|
2712
|
+
return None
|
|
2713
|
+
current = (
|
|
2714
|
+
os.environ.get("SEDIMENT_URL", cfg.get("current"))
|
|
2715
|
+
if capture
|
|
2716
|
+
else cfg.get("current")
|
|
2717
|
+
)
|
|
2718
|
+
if not isinstance(current, str) or not current or not _safe_server_url(current):
|
|
2719
|
+
return None
|
|
2720
|
+
servers = cfg.get("servers", {})
|
|
2721
|
+
if not isinstance(servers, dict):
|
|
2722
|
+
return None
|
|
2723
|
+
entry = servers.get(current)
|
|
2724
|
+
if capture:
|
|
2725
|
+
override = os.environ.get("SEDIMENT_INGEST_TOKEN")
|
|
2726
|
+
if override:
|
|
2727
|
+
_verify_capture_override(current, override)
|
|
2728
|
+
return current, override
|
|
2729
|
+
if entry is None:
|
|
2730
|
+
return None
|
|
2731
|
+
if (
|
|
2732
|
+
not isinstance(entry, dict)
|
|
2733
|
+
or entry.get("capture_authority") != "ingest"
|
|
2734
|
+
or not isinstance(entry.get("capture_client_id"), str)
|
|
2735
|
+
or not entry["capture_client_id"]
|
|
2736
|
+
):
|
|
2737
|
+
raise ValueError(_CAPTURE_LOGIN)
|
|
2738
|
+
token = entry.get("capture_token")
|
|
2739
|
+
if (
|
|
2740
|
+
not isinstance(token, str)
|
|
2741
|
+
or not token
|
|
2742
|
+
or token == entry.get("token")
|
|
2743
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in token)
|
|
2744
|
+
):
|
|
2745
|
+
raise ValueError(_CAPTURE_LOGIN)
|
|
2746
|
+
return current, token
|
|
2747
|
+
token = entry.get("token") if isinstance(entry, dict) else None
|
|
2748
|
+
if isinstance(token, str) and token:
|
|
2749
|
+
return current, token
|
|
2750
|
+
return None
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
def _verify_capture_override(url: str, token: str) -> None:
|
|
2754
|
+
"""Verify an explicit override with the destination before writing any file."""
|
|
2755
|
+
if not _safe_server_url(url) or any(ord(c) < 32 or ord(c) == 127 for c in token):
|
|
2756
|
+
raise ValueError("capture override must have verified ingest authority")
|
|
2757
|
+
request = urllib.request.Request(
|
|
2758
|
+
f"{url.rstrip('/')}/v1/me", headers={"Authorization": f"Bearer {token}"}
|
|
2759
|
+
)
|
|
2760
|
+
try:
|
|
2761
|
+
opener = urllib.request.build_opener(_RejectRedirects())
|
|
2762
|
+
with opener.open(request, timeout=5) as response:
|
|
2763
|
+
raw = response.read(8193)
|
|
2764
|
+
if len(raw) > 8192:
|
|
2765
|
+
raise ValueError
|
|
2766
|
+
identity = json.loads(raw)
|
|
2767
|
+
if (
|
|
2768
|
+
not isinstance(identity, dict)
|
|
2769
|
+
or identity.get("authority") != "ingest"
|
|
2770
|
+
or not isinstance(identity.get("client_id"), str)
|
|
2771
|
+
or not identity["client_id"]
|
|
2772
|
+
):
|
|
2773
|
+
raise ValueError
|
|
2774
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError, TypeError):
|
|
2775
|
+
raise ValueError(
|
|
2776
|
+
"capture override must have verified ingest authority"
|
|
2777
|
+
) from None
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
def _sh_env_file() -> Path:
|
|
2781
|
+
return Path.home() / ".sediment" / "env.sh"
|
|
2782
|
+
|
|
2783
|
+
|
|
2784
|
+
def _fish_env_file() -> Path:
|
|
2785
|
+
return Path.home() / ".config" / "fish" / "conf.d" / "sediment.fish"
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def _env_pairs(
|
|
2789
|
+
url: str,
|
|
2790
|
+
token: str,
|
|
2791
|
+
user_id: str | None,
|
|
2792
|
+
gateway_url: str | None,
|
|
2793
|
+
gateway_key: str | None,
|
|
2794
|
+
) -> list[tuple[str, str]]:
|
|
2795
|
+
"""The §5.1 block, generated. The exporter appends /v1/logs itself, so
|
|
2796
|
+
the endpoint is the bare server URL."""
|
|
2797
|
+
pairs = [
|
|
2798
|
+
("CLAUDE_CODE_ENABLE_TELEMETRY", "1"),
|
|
2799
|
+
("OTEL_LOGS_EXPORTER", "otlp"),
|
|
2800
|
+
("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
|
|
2801
|
+
("OTEL_EXPORTER_OTLP_ENDPOINT", url),
|
|
2802
|
+
("OTEL_EXPORTER_OTLP_HEADERS", f"Authorization=Bearer {token}"),
|
|
2803
|
+
("OTEL_LOG_TOOL_DETAILS", "1"),
|
|
2804
|
+
("SEDIMENT_INGEST_TOKEN", token),
|
|
2805
|
+
]
|
|
2806
|
+
try:
|
|
2807
|
+
_transcript_client()._validated_endpoint(url)
|
|
2808
|
+
pairs.append(("SEDIMENT_OTLP_ENDPOINT", url))
|
|
2809
|
+
except ValueError:
|
|
2810
|
+
_warn(f"capture endpoint rejected; {_CAPTURE_ENDPOINT_FORMS}")
|
|
2811
|
+
except (ImportError, OSError, RuntimeError, SyntaxError):
|
|
2812
|
+
_warn("capture helper unavailable; dedicated hook delivery is disabled")
|
|
2813
|
+
if user_id:
|
|
2814
|
+
pairs.append(("OTEL_RESOURCE_ATTRIBUTES", f"user.id={user_id}"))
|
|
2815
|
+
if gateway_url:
|
|
2816
|
+
pairs.append(("ANTHROPIC_BASE_URL", gateway_url))
|
|
2817
|
+
if gateway_key:
|
|
2818
|
+
pairs.append(("ANTHROPIC_AUTH_TOKEN", gateway_key))
|
|
2819
|
+
pairs.append(("SEDIMENT_GATEWAY_KEY", gateway_key))
|
|
2820
|
+
return pairs
|
|
2821
|
+
|
|
2822
|
+
|
|
2823
|
+
def _write_0600(path: Path, content: str) -> None:
|
|
2824
|
+
"""0600 on create AND on rewrite — O_CREAT's mode applies only at
|
|
2825
|
+
creation, and these files carry the bearer token."""
|
|
2826
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2827
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
2828
|
+
os.fchmod(fd, 0o600)
|
|
2829
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2830
|
+
fh.write(content)
|
|
2831
|
+
|
|
2832
|
+
|
|
2833
|
+
def _write_env_files(pairs: list[tuple[str, str]]) -> None:
|
|
2834
|
+
header = "# Written by `sediment install` — rewritten on re-run.\n"
|
|
2835
|
+
sh = header + "".join(f"export {k}={shlex.quote(v)}\n" for k, v in pairs)
|
|
2836
|
+
# fish accepts the same single-quote escaping shlex emits.
|
|
2837
|
+
fish = header + "".join(f"set -gx {k} {shlex.quote(v)}\n" for k, v in pairs)
|
|
2838
|
+
_write_0600(_sh_env_file(), sh)
|
|
2839
|
+
_write_0600(_fish_env_file(), fish)
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
def _wire_profiles() -> list[Path]:
|
|
2843
|
+
"""Append the guarded source block to profiles that exist (idempotent);
|
|
2844
|
+
when none exist, create .zprofile + .profile so both login-shell
|
|
2845
|
+
families pick it up. fish needs nothing — conf.d auto-loads."""
|
|
2846
|
+
block = (
|
|
2847
|
+
f"\n{ENV_BLOCK_BEGIN}\n"
|
|
2848
|
+
'[ -f "$HOME/.sediment/env.sh" ] && . "$HOME/.sediment/env.sh"\n'
|
|
2849
|
+
f"{ENV_BLOCK_END}\n"
|
|
2850
|
+
)
|
|
2851
|
+
targets = [Path.home() / n for n in _PROFILE_NAMES if (Path.home() / n).exists()]
|
|
2852
|
+
if not targets:
|
|
2853
|
+
targets = [Path.home() / ".zprofile", Path.home() / ".profile"]
|
|
2854
|
+
wired: list[Path] = []
|
|
2855
|
+
for target in targets:
|
|
2856
|
+
content = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
2857
|
+
if ENV_BLOCK_BEGIN not in content:
|
|
2858
|
+
target.write_text(content + block, encoding="utf-8")
|
|
2859
|
+
wired.append(target)
|
|
2860
|
+
return wired
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
def _unwire_env() -> list[str]:
|
|
2864
|
+
"""Uninstall's inverse: remove the env files and strip the guarded
|
|
2865
|
+
blocks. Returns human-readable descriptions of what was removed."""
|
|
2866
|
+
removed: list[str] = []
|
|
2867
|
+
for path in (_sh_env_file(), _fish_env_file()):
|
|
2868
|
+
try:
|
|
2869
|
+
path.unlink()
|
|
2870
|
+
removed.append(str(path))
|
|
2871
|
+
except OSError:
|
|
2872
|
+
pass
|
|
2873
|
+
# Also eat the blank line _wire_profiles prepends, so wire/unwire cycles
|
|
2874
|
+
# leave the profile byte-identical.
|
|
2875
|
+
pattern = re.compile(
|
|
2876
|
+
r"\n?"
|
|
2877
|
+
+ re.escape(ENV_BLOCK_BEGIN)
|
|
2878
|
+
+ r".*?"
|
|
2879
|
+
+ re.escape(ENV_BLOCK_END)
|
|
2880
|
+
+ r"\n?",
|
|
2881
|
+
re.DOTALL,
|
|
2882
|
+
)
|
|
2883
|
+
for name in _PROFILE_NAMES:
|
|
2884
|
+
profile = Path.home() / name
|
|
2885
|
+
try:
|
|
2886
|
+
content = profile.read_text(encoding="utf-8")
|
|
2887
|
+
except OSError:
|
|
2888
|
+
continue
|
|
2889
|
+
stripped = pattern.sub("", content)
|
|
2890
|
+
if stripped != content:
|
|
2891
|
+
profile.write_text(stripped, encoding="utf-8")
|
|
2892
|
+
removed.append(f"{profile} (env block)")
|
|
2893
|
+
return removed
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def cmd_install_env(
|
|
2897
|
+
user_id: str | None,
|
|
2898
|
+
gateway_url: str | None,
|
|
2899
|
+
gateway_key: str | None,
|
|
2900
|
+
transcripts: bool = False,
|
|
2901
|
+
) -> str:
|
|
2902
|
+
"""Wire the agent telemetry env from the login config; the install
|
|
2903
|
+
summary string says what happened."""
|
|
2904
|
+
cfg = _cli_config(capture=True)
|
|
2905
|
+
if cfg is None:
|
|
2906
|
+
return f"skipped (capture credential is absent; {_CAPTURE_LOGIN})"
|
|
2907
|
+
url, token = cfg
|
|
2908
|
+
pairs = _env_pairs(url, token, user_id, gateway_url, gateway_key)
|
|
2909
|
+
try:
|
|
2910
|
+
previous = _sh_env_file().read_text(encoding="utf-8").splitlines()
|
|
2911
|
+
except FileNotFoundError:
|
|
2912
|
+
previous = []
|
|
2913
|
+
if transcripts or "export SEDIMENT_PI_TRANSCRIPTS=1" in previous:
|
|
2914
|
+
pairs.append(("SEDIMENT_PI_TRANSCRIPTS", "1"))
|
|
2915
|
+
directory = _delivery_directory(previous)
|
|
2916
|
+
if directory:
|
|
2917
|
+
pairs.append(("SEDIMENT_DELIVERY_DIR", directory))
|
|
2918
|
+
_write_env_files(pairs)
|
|
2919
|
+
wired = _wire_profiles()
|
|
2920
|
+
profiles = ", ".join(p.name for p in wired)
|
|
2921
|
+
return (
|
|
2922
|
+
f"wrote {_sh_env_file()} and {_fish_env_file()} (0600), sourced from "
|
|
2923
|
+
f"{profiles}; restart running agent sessions to pick it up"
|
|
2924
|
+
)
|
|
2925
|
+
|
|
2926
|
+
|
|
2927
|
+
def _delivery_directory(previous: list[str] | None = None) -> str | None:
|
|
2928
|
+
"""Preserve explicit consent as literal data; never source saved shell code."""
|
|
2929
|
+
if "SEDIMENT_DELIVERY_DIR" in os.environ:
|
|
2930
|
+
value = os.environ["SEDIMENT_DELIVERY_DIR"]
|
|
2931
|
+
else:
|
|
2932
|
+
if previous is None:
|
|
2933
|
+
try:
|
|
2934
|
+
previous = _sh_env_file().read_text(encoding="utf-8").splitlines()
|
|
2935
|
+
except FileNotFoundError:
|
|
2936
|
+
previous = []
|
|
2937
|
+
value = ""
|
|
2938
|
+
for line in previous:
|
|
2939
|
+
if not line.startswith("export SEDIMENT_DELIVERY_DIR="):
|
|
2940
|
+
continue
|
|
2941
|
+
fields = shlex.split(line)
|
|
2942
|
+
if len(fields) != 2 or fields[0] != "export":
|
|
2943
|
+
raise ValueError("saved delivery enrollment is invalid")
|
|
2944
|
+
value = fields[1].partition("=")[2]
|
|
2945
|
+
value = value.strip()
|
|
2946
|
+
if any(character in value for character in "\0\r\n"):
|
|
2947
|
+
raise ValueError("delivery enrollment is invalid")
|
|
2948
|
+
return value or None
|
|
2949
|
+
|
|
2950
|
+
|
|
2951
|
+
def _doctor_delivery(directory: str | None) -> Finding:
|
|
2952
|
+
directory = (directory or "").strip()
|
|
2953
|
+
if not directory:
|
|
2954
|
+
return (DOCTOR_INFO, "delivery", "best_effort; buffering is disabled")
|
|
2955
|
+
try:
|
|
2956
|
+
helper = _capture_client("delivery")
|
|
2957
|
+
if (
|
|
2958
|
+
getattr(helper, "FORMAT_VERSION", None) != 1
|
|
2959
|
+
or not callable(getattr(helper, "status", None))
|
|
2960
|
+
or not callable(getattr(helper, "watch", None))
|
|
2961
|
+
):
|
|
2962
|
+
raise RuntimeError
|
|
2963
|
+
except (ImportError, OSError, ValueError, RuntimeError, SyntaxError):
|
|
2964
|
+
return (
|
|
2965
|
+
DOCTOR_FAIL,
|
|
2966
|
+
"delivery",
|
|
2967
|
+
"helper unavailable; install the matching helper before starting a worker",
|
|
2968
|
+
)
|
|
2969
|
+
try:
|
|
2970
|
+
state = helper.status(directory)
|
|
2971
|
+
except (OSError, ValueError, RuntimeError):
|
|
2972
|
+
return (
|
|
2973
|
+
DOCTOR_FAIL,
|
|
2974
|
+
"delivery",
|
|
2975
|
+
"private storage is unsafe or unreadable; worker enrollment is incomplete",
|
|
2976
|
+
)
|
|
2977
|
+
if (
|
|
2978
|
+
not isinstance(state, dict)
|
|
2979
|
+
or state.get("format_version") != 1
|
|
2980
|
+
or type(state.get("worker_running")) is not bool
|
|
2981
|
+
):
|
|
2982
|
+
return (
|
|
2983
|
+
DOCTOR_FAIL,
|
|
2984
|
+
"delivery",
|
|
2985
|
+
"helper status is incompatible; install the matching helper before starting a worker",
|
|
2986
|
+
)
|
|
2987
|
+
path = Path(directory)
|
|
2988
|
+
if not path.is_dir():
|
|
2989
|
+
return (
|
|
2990
|
+
DOCTOR_FAIL,
|
|
2991
|
+
"delivery",
|
|
2992
|
+
"private storage is missing; start a supervised delivery worker",
|
|
2993
|
+
)
|
|
2994
|
+
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
|
|
2995
|
+
return (
|
|
2996
|
+
DOCTOR_FAIL,
|
|
2997
|
+
"delivery",
|
|
2998
|
+
"private storage is not writable; worker enrollment is incomplete",
|
|
2999
|
+
)
|
|
3000
|
+
if not state["worker_running"]:
|
|
3001
|
+
return (
|
|
3002
|
+
DOCTOR_FAIL,
|
|
3003
|
+
"delivery",
|
|
3004
|
+
"helper available, private writable storage; worker is not running; start delivery replay --watch under a process supervisor",
|
|
3005
|
+
)
|
|
3006
|
+
return (
|
|
3007
|
+
DOCTOR_OK,
|
|
3008
|
+
"delivery",
|
|
3009
|
+
"buffered; helper available, private writable storage, worker running",
|
|
3010
|
+
)
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
_CAPTURE_ENDPOINT_FORMS = (
|
|
3014
|
+
"use HTTPS for remote hosts or HTTP for literal loopback; "
|
|
3015
|
+
"use the origin or /v1/logs path without credentials, query, or fragment"
|
|
3016
|
+
)
|
|
3017
|
+
|
|
3018
|
+
|
|
3019
|
+
def _doctor_capture_endpoint(required: bool = False) -> Finding:
|
|
3020
|
+
configured = os.environ.get("SEDIMENT_OTLP_ENDPOINT")
|
|
3021
|
+
if not configured:
|
|
3022
|
+
return (
|
|
3023
|
+
DOCTOR_FAIL if required else DOCTOR_INFO,
|
|
3024
|
+
"capture endpoint",
|
|
3025
|
+
"SEDIMENT_OTLP_ENDPOINT is unset; dedicated hook delivery is disabled",
|
|
3026
|
+
)
|
|
3027
|
+
try:
|
|
3028
|
+
_transcript_client()._validated_endpoint(configured)
|
|
3029
|
+
except ValueError:
|
|
3030
|
+
return (DOCTOR_FAIL, "capture endpoint", f"rejected; {_CAPTURE_ENDPOINT_FORMS}")
|
|
3031
|
+
except (ImportError, OSError, RuntimeError, SyntaxError):
|
|
3032
|
+
return (
|
|
3033
|
+
DOCTOR_FAIL,
|
|
3034
|
+
"capture endpoint",
|
|
3035
|
+
"capture helper unavailable; install the matching capture clients",
|
|
3036
|
+
)
|
|
3037
|
+
return (DOCTOR_OK, "capture endpoint", "SEDIMENT_OTLP_ENDPOINT is accepted")
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
_CODEX_OTEL_BEGIN = "# BEGIN SEDIMENT CODEX TELEMETRY"
|
|
3041
|
+
_CODEX_OTEL_END = "# END SEDIMENT CODEX TELEMETRY"
|
|
3042
|
+
_CODEX_OTEL_BLOCK = re.compile(
|
|
3043
|
+
rf"^{_CODEX_OTEL_BEGIN}\n.*?^{_CODEX_OTEL_END}\n?", re.MULTILINE | re.DOTALL
|
|
3044
|
+
)
|
|
3045
|
+
|
|
3046
|
+
|
|
3047
|
+
def _without_codex_telemetry(original: str) -> str:
|
|
3048
|
+
"""Verify that removing the marked table preserves every unmanaged setting."""
|
|
3049
|
+
try:
|
|
3050
|
+
parsed = tomllib.loads(original)
|
|
3051
|
+
managed = _CODEX_OTEL_BLOCK.findall(original)
|
|
3052
|
+
if (
|
|
3053
|
+
original.count(_CODEX_OTEL_BEGIN) != len(managed)
|
|
3054
|
+
or original.count(_CODEX_OTEL_END) != len(managed)
|
|
3055
|
+
or len(managed) > 1
|
|
3056
|
+
or (managed and "otel" not in parsed)
|
|
3057
|
+
):
|
|
3058
|
+
raise ValueError
|
|
3059
|
+
remaining = _CODEX_OTEL_BLOCK.sub("", original)
|
|
3060
|
+
unmanaged = tomllib.loads(remaining)
|
|
3061
|
+
if "otel" in unmanaged or unmanaged != {
|
|
3062
|
+
key: value for key, value in parsed.items() if key != "otel"
|
|
3063
|
+
}:
|
|
3064
|
+
raise ValueError
|
|
3065
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
3066
|
+
raise ValueError(
|
|
3067
|
+
"Codex profile contains malformed content or an unmanaged [otel] table; choose another profile"
|
|
3068
|
+
) from None
|
|
3069
|
+
return remaining
|
|
3070
|
+
|
|
3071
|
+
|
|
3072
|
+
def _install_codex_profile(name: str) -> Path:
|
|
3073
|
+
"""Resolve login credentials into one managed table, preserving other settings."""
|
|
3074
|
+
cfg = _cli_config(capture=True)
|
|
3075
|
+
if cfg is None:
|
|
3076
|
+
raise ValueError(_CAPTURE_LOGIN)
|
|
3077
|
+
url, token = cfg
|
|
3078
|
+
try:
|
|
3079
|
+
endpoint = _transcript_client()._validated_endpoint(url)
|
|
3080
|
+
except ValueError:
|
|
3081
|
+
raise ValueError(
|
|
3082
|
+
f"capture endpoint rejected; {_CAPTURE_ENDPOINT_FORMS}"
|
|
3083
|
+
) from None
|
|
3084
|
+
except (ImportError, OSError, RuntimeError, SyntaxError):
|
|
3085
|
+
raise ValueError(
|
|
3086
|
+
"capture helper unavailable; install the matching capture clients"
|
|
3087
|
+
) from None
|
|
3088
|
+
if "\r" in token or "\n" in token:
|
|
3089
|
+
raise ValueError("configured token cannot be used as an HTTP header")
|
|
3090
|
+
path = _codex_hooks_path().with_name(f"{name}.config.toml")
|
|
3091
|
+
if path.is_symlink():
|
|
3092
|
+
raise ValueError(
|
|
3093
|
+
"Codex profile is a symbolic link; choose a regular profile file"
|
|
3094
|
+
)
|
|
3095
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
3096
|
+
remaining = _without_codex_telemetry(original)
|
|
3097
|
+
prefix = remaining + ("\n" if remaining and not remaining.endswith("\n") else "")
|
|
3098
|
+
block = (
|
|
3099
|
+
f'{_CODEX_OTEL_BEGIN}\n[otel]\nenvironment = "sediment"\n'
|
|
3100
|
+
"exporter = { otlp-http = { endpoint = "
|
|
3101
|
+
+ json.dumps(endpoint)
|
|
3102
|
+
+ ', protocol = "json", headers = { "Authorization" = '
|
|
3103
|
+
+ json.dumps(f"Bearer {token}")
|
|
3104
|
+
+ " } } }\nlog_user_prompt = false\n"
|
|
3105
|
+
+ f"{_CODEX_OTEL_END}\n"
|
|
3106
|
+
)
|
|
3107
|
+
tomllib.loads(prefix + block)
|
|
3108
|
+
_write_0600(path, prefix + block)
|
|
3109
|
+
return path
|
|
3110
|
+
|
|
3111
|
+
|
|
3112
|
+
def _uninstall_codex_profiles() -> bool:
|
|
3113
|
+
"""Remove verified managed telemetry, reporting profiles that need attention."""
|
|
3114
|
+
directory = _codex_hooks_path().parent
|
|
3115
|
+
try:
|
|
3116
|
+
profiles = sorted(
|
|
3117
|
+
path for path in directory.iterdir() if path.name.endswith(".config.toml")
|
|
3118
|
+
)
|
|
3119
|
+
except FileNotFoundError:
|
|
3120
|
+
return True
|
|
3121
|
+
except OSError:
|
|
3122
|
+
_warn(f"Codex telemetry cleanup skipped: cannot read {directory}")
|
|
3123
|
+
return False
|
|
3124
|
+
complete = True
|
|
3125
|
+
for path in profiles:
|
|
3126
|
+
try:
|
|
3127
|
+
if path.is_symlink() or not path.is_file():
|
|
3128
|
+
raise ValueError
|
|
3129
|
+
original = path.read_text(encoding="utf-8")
|
|
3130
|
+
if _CODEX_OTEL_BEGIN not in original and _CODEX_OTEL_END not in original:
|
|
3131
|
+
continue
|
|
3132
|
+
remaining = _without_codex_telemetry(original)
|
|
3133
|
+
if remaining.strip():
|
|
3134
|
+
_write_0600(path, remaining)
|
|
3135
|
+
else:
|
|
3136
|
+
path.unlink()
|
|
3137
|
+
except (OSError, UnicodeError, ValueError):
|
|
3138
|
+
_warn(
|
|
3139
|
+
f"Codex telemetry cleanup skipped {path}: "
|
|
3140
|
+
"malformed, unreadable, or not a regular file; inspect it manually"
|
|
3141
|
+
)
|
|
3142
|
+
complete = False
|
|
3143
|
+
continue
|
|
3144
|
+
print(f"{ui.glyph('✓', 'phosphor')}removed managed Codex telemetry from {path}")
|
|
3145
|
+
return complete
|
|
3146
|
+
|
|
3147
|
+
|
|
3148
|
+
class _RejectRedirects(urllib.request.HTTPRedirectHandler):
|
|
3149
|
+
def http_error_302(self, req, fp, code, msg, headers):
|
|
3150
|
+
# Reject before urllib parses Location; malformed redirect targets can
|
|
3151
|
+
# raise ValueError before redirect_request() runs.
|
|
3152
|
+
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
|
|
3153
|
+
|
|
3154
|
+
http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
|
|
3155
|
+
|
|
3156
|
+
|
|
3157
|
+
def _doctor_session_evidence(
|
|
3158
|
+
findings: list[Finding],
|
|
3159
|
+
repo: Path,
|
|
3160
|
+
agent: str,
|
|
3161
|
+
session_id: str,
|
|
3162
|
+
transcripts: bool,
|
|
3163
|
+
inference_calls: bool,
|
|
3164
|
+
) -> None:
|
|
3165
|
+
"""Require visible Session evidence and its local HEAD note, without persisting it."""
|
|
3166
|
+
marked = any(
|
|
3167
|
+
entry.get("tool") == agent and entry.get("session_id") == session_id
|
|
3168
|
+
for entry in _read_note_sessions("HEAD", repo)
|
|
3169
|
+
)
|
|
3170
|
+
findings.append(
|
|
3171
|
+
(
|
|
3172
|
+
DOCTOR_OK if marked else DOCTOR_FAIL,
|
|
3173
|
+
"commit Session note",
|
|
3174
|
+
"observed in HEAD"
|
|
3175
|
+
if marked
|
|
3176
|
+
else "missing from HEAD for this harness and Session",
|
|
3177
|
+
)
|
|
3178
|
+
)
|
|
3179
|
+
required = [("developer_decision", "Developer decision")]
|
|
3180
|
+
for requested, kind, check in (
|
|
3181
|
+
(transcripts, "edit_observation", "Edit observation"),
|
|
3182
|
+
(inference_calls, "inference_call", "Session Inference call"),
|
|
3183
|
+
):
|
|
3184
|
+
if requested:
|
|
3185
|
+
if agent == "cursor":
|
|
3186
|
+
findings.append((DOCTOR_FAIL, check, "unsupported for Cursor"))
|
|
3187
|
+
else:
|
|
3188
|
+
required.append((kind, check))
|
|
3189
|
+
cfg = _cli_config()
|
|
3190
|
+
if cfg is None:
|
|
3191
|
+
findings.append(
|
|
3192
|
+
(DOCTOR_FAIL, "Session evidence", "not logged in; run sediment login")
|
|
3193
|
+
)
|
|
3194
|
+
return
|
|
3195
|
+
url, token = cfg
|
|
3196
|
+
request = urllib.request.Request(
|
|
3197
|
+
f"{url.rstrip('/')}/query/session/{urllib.parse.quote(session_id, safe='')}",
|
|
3198
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": _DOCTOR_USER_AGENT},
|
|
3199
|
+
)
|
|
3200
|
+
try:
|
|
3201
|
+
opener = urllib.request.build_opener(_RejectRedirects())
|
|
3202
|
+
with opener.open(request, timeout=5) as response:
|
|
3203
|
+
raw = response.read(8 * 1024 * 1024 + 1)
|
|
3204
|
+
if len(raw) > 8 * 1024 * 1024:
|
|
3205
|
+
raise ValueError
|
|
3206
|
+
body = json.loads(raw)
|
|
3207
|
+
if not isinstance(body, dict) or not isinstance(body.get("found"), bool):
|
|
3208
|
+
raise ValueError
|
|
3209
|
+
if not body["found"]:
|
|
3210
|
+
findings.append((DOCTOR_FAIL, "Session evidence", "Session is missing"))
|
|
3211
|
+
return
|
|
3212
|
+
if (
|
|
3213
|
+
body.get("session_id") != session_id
|
|
3214
|
+
or type(body.get("omitted_events")) is not int
|
|
3215
|
+
):
|
|
3216
|
+
raise ValueError
|
|
3217
|
+
if body["omitted_events"] != 0:
|
|
3218
|
+
findings.append(
|
|
3219
|
+
(DOCTOR_FAIL, "Session evidence", "incomplete; some events are omitted")
|
|
3220
|
+
)
|
|
3221
|
+
return
|
|
3222
|
+
timeline = body.get("timeline")
|
|
3223
|
+
if not isinstance(timeline, list):
|
|
3224
|
+
raise ValueError
|
|
3225
|
+
for entry in timeline:
|
|
3226
|
+
if (
|
|
3227
|
+
not isinstance(entry, dict)
|
|
3228
|
+
or not isinstance(entry.get("fact_id"), str)
|
|
3229
|
+
or not entry["fact_id"]
|
|
3230
|
+
or not isinstance(entry.get("occurred_at"), str)
|
|
3231
|
+
or _parse_instant(entry["occurred_at"]) is None
|
|
3232
|
+
or entry.get("event_type")
|
|
3233
|
+
not in {
|
|
3234
|
+
"inference_call",
|
|
3235
|
+
"developer_decision",
|
|
3236
|
+
"edit_observation",
|
|
3237
|
+
"rejected_edit",
|
|
3238
|
+
"retry_linkage",
|
|
3239
|
+
}
|
|
3240
|
+
):
|
|
3241
|
+
raise ValueError
|
|
3242
|
+
except urllib.error.HTTPError as exc:
|
|
3243
|
+
findings.append(
|
|
3244
|
+
(DOCTOR_FAIL, "Session evidence", f"HTTP {exc.code}; verification failed")
|
|
3245
|
+
)
|
|
3246
|
+
return
|
|
3247
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
3248
|
+
findings.append(
|
|
3249
|
+
(DOCTOR_FAIL, "Session evidence", "unreachable; verification failed")
|
|
3250
|
+
)
|
|
3251
|
+
return
|
|
3252
|
+
except (ValueError, TypeError, UnicodeError):
|
|
3253
|
+
findings.append(
|
|
3254
|
+
(
|
|
3255
|
+
DOCTOR_FAIL,
|
|
3256
|
+
"Session evidence",
|
|
3257
|
+
"unreadable response; verification failed",
|
|
3258
|
+
)
|
|
3259
|
+
)
|
|
3260
|
+
return
|
|
3261
|
+
for kind, check in required:
|
|
3262
|
+
observed = any(
|
|
3263
|
+
entry["event_type"] == kind
|
|
3264
|
+
and (kind == "inference_call" or entry.get("agent_harness") == agent)
|
|
3265
|
+
for entry in timeline
|
|
3266
|
+
)
|
|
3267
|
+
findings.append(
|
|
3268
|
+
(
|
|
3269
|
+
DOCTOR_OK if observed else DOCTOR_FAIL,
|
|
3270
|
+
check,
|
|
3271
|
+
"observed in this Session" if observed else "missing from this Session",
|
|
3272
|
+
)
|
|
3273
|
+
)
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
def _doctor_server(findings: list[Finding]) -> None:
|
|
3277
|
+
"""Server section: reachability, token validity, version skew.
|
|
3278
|
+
stdlib urllib, so the copied-file fleet form stays dependency-free."""
|
|
3279
|
+
cfg = _cli_config()
|
|
3280
|
+
if cfg is None:
|
|
3281
|
+
findings.append(
|
|
3282
|
+
(
|
|
3283
|
+
DOCTOR_INFO,
|
|
3284
|
+
"server",
|
|
3285
|
+
"not logged in (sediment login <url>) — remote verbs and "
|
|
3286
|
+
"env wiring unavailable",
|
|
3287
|
+
)
|
|
3288
|
+
)
|
|
3289
|
+
return
|
|
3290
|
+
url, token = cfg
|
|
3291
|
+
check = f"server[{url}]"
|
|
3292
|
+
request = urllib.request.Request(
|
|
3293
|
+
f"{url.rstrip('/')}/v1/me",
|
|
3294
|
+
headers={
|
|
3295
|
+
"Authorization": f"Bearer {token}",
|
|
3296
|
+
"User-Agent": _DOCTOR_USER_AGENT,
|
|
3297
|
+
},
|
|
3298
|
+
)
|
|
3299
|
+
try:
|
|
3300
|
+
opener = urllib.request.build_opener(_RejectRedirects())
|
|
3301
|
+
with opener.open(request, timeout=5) as resp:
|
|
3302
|
+
raw = resp.read(8193)
|
|
3303
|
+
if len(raw) > 8192:
|
|
3304
|
+
raise ValueError
|
|
3305
|
+
body = json.loads(raw)
|
|
3306
|
+
except urllib.error.HTTPError as exc:
|
|
3307
|
+
detail = (
|
|
3308
|
+
"token rejected (401) — re-run sediment login"
|
|
3309
|
+
if exc.code == 401
|
|
3310
|
+
else f"HTTP {exc.code}"
|
|
3311
|
+
)
|
|
3312
|
+
findings.append((DOCTOR_FAIL, check, detail))
|
|
3313
|
+
return
|
|
3314
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
3315
|
+
findings.append((DOCTOR_FAIL, check, f"unreachable ({exc.__class__.__name__})"))
|
|
3316
|
+
return
|
|
3317
|
+
except (ValueError, TypeError, UnicodeError):
|
|
3318
|
+
findings.append(
|
|
3319
|
+
(DOCTOR_FAIL, check, "unreadable identity response; verification failed")
|
|
3320
|
+
)
|
|
3321
|
+
return
|
|
3322
|
+
if not isinstance(body, dict) or body.get("authority") != "operator":
|
|
3323
|
+
findings.append(
|
|
3324
|
+
(
|
|
3325
|
+
DOCTOR_FAIL,
|
|
3326
|
+
check,
|
|
3327
|
+
"operator authority required; run sediment login with an operator token",
|
|
3328
|
+
)
|
|
3329
|
+
)
|
|
3330
|
+
return
|
|
3331
|
+
detail = f"reachable, operator token valid (org {body.get('org_id')})"
|
|
3332
|
+
try:
|
|
3333
|
+
from sediment_api import __version__ as client_version
|
|
3334
|
+
except ImportError: # standalone copied-file run — skew unknowable
|
|
3335
|
+
client_version = None
|
|
3336
|
+
server_version = body.get("version")
|
|
3337
|
+
if server_version and client_version and server_version != client_version:
|
|
3338
|
+
findings.append(
|
|
3339
|
+
(
|
|
3340
|
+
DOCTOR_INFO,
|
|
3341
|
+
check,
|
|
3342
|
+
f"{detail}; version skew: server {server_version}, client "
|
|
3343
|
+
f"{client_version} — uv tool upgrade sediment-cli",
|
|
3344
|
+
)
|
|
3345
|
+
)
|
|
3346
|
+
else:
|
|
3347
|
+
findings.append((DOCTOR_OK, check, detail))
|
|
3348
|
+
|
|
3349
|
+
|
|
3350
|
+
def cmd_install(
|
|
3351
|
+
repo: str,
|
|
3352
|
+
agents: bool,
|
|
3353
|
+
transcripts: bool = False,
|
|
3354
|
+
env: bool = True,
|
|
3355
|
+
user_id: str | None = None,
|
|
3356
|
+
gateway_url: str | None = None,
|
|
3357
|
+
gateway_key: str | None = None,
|
|
3358
|
+
codex_profile: str | None = None,
|
|
3359
|
+
) -> int:
|
|
3360
|
+
repo_path = Path(repo).resolve()
|
|
3361
|
+
if _git_dir(repo_path) is None:
|
|
3362
|
+
_error(f"{repo_path} is not a git work tree")
|
|
3363
|
+
return 1
|
|
3364
|
+
hooks_dir = _hooks_dir(repo_path)
|
|
3365
|
+
if hooks_dir is None:
|
|
3366
|
+
_error(f"could not resolve the hooks dir for {repo_path}")
|
|
3367
|
+
return 1
|
|
3368
|
+
if codex_profile is not None:
|
|
3369
|
+
try:
|
|
3370
|
+
profile = _install_codex_profile(codex_profile)
|
|
3371
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
3372
|
+
_error(
|
|
3373
|
+
str(exc)
|
|
3374
|
+
if isinstance(exc, ValueError)
|
|
3375
|
+
else "Codex profile could not be written"
|
|
3376
|
+
)
|
|
3377
|
+
return 1
|
|
3378
|
+
print(
|
|
3379
|
+
f"Codex telemetry profile: wrote {profile} (0600); start codex --profile {codex_profile}"
|
|
3380
|
+
)
|
|
3381
|
+
installed = _install_repo_hooks(hooks_dir, repo_path)
|
|
3382
|
+
ok = ui.glyph("✓", "phosphor")
|
|
3383
|
+
print(
|
|
3384
|
+
f"{ok}installed git hooks ({', '.join(installed) or 'none'}) in {hooks_dir} "
|
|
3385
|
+
"and set notes.rewriteRef"
|
|
3386
|
+
)
|
|
3387
|
+
if agents:
|
|
3388
|
+
claude_status = _install_claude_hook()
|
|
3389
|
+
codex_status = _install_codex_hook()
|
|
3390
|
+
cursor_status = _install_cursor_hooks()
|
|
3391
|
+
pi_status = _install_pi_extension()
|
|
3392
|
+
print(
|
|
3393
|
+
f"{ok}agent hooks: "
|
|
3394
|
+
f"claude-code {claude_status}, codex {codex_status}, "
|
|
3395
|
+
f"cursor {cursor_status}, pi extension {pi_status}"
|
|
3396
|
+
)
|
|
3397
|
+
if codex_status != "skipped":
|
|
3398
|
+
print(
|
|
3399
|
+
"Codex skips hooks until you trust them. In Codex, run /hooks "
|
|
3400
|
+
"and trust the Sediment hook."
|
|
3401
|
+
)
|
|
3402
|
+
# Env wiring is user-level like the agent hooks, so it rides the
|
|
3403
|
+
# same flag pair: --no-agents implies no env wiring.
|
|
3404
|
+
if env:
|
|
3405
|
+
try:
|
|
3406
|
+
detail = cmd_install_env(user_id, gateway_url, gateway_key, transcripts)
|
|
3407
|
+
except ValueError as exc:
|
|
3408
|
+
_error(str(exc))
|
|
3409
|
+
return 1
|
|
3410
|
+
except (OSError, UnicodeError, RuntimeError, ImportError):
|
|
3411
|
+
_error(
|
|
3412
|
+
"agent environment could not be written; enrollment is incomplete"
|
|
3413
|
+
)
|
|
3414
|
+
return 1
|
|
3415
|
+
print(f"{ok}agent env: {detail}")
|
|
3416
|
+
if transcripts:
|
|
3417
|
+
verdict, _, detail = _doctor_capture_endpoint()
|
|
3418
|
+
if verdict == DOCTOR_FAIL:
|
|
3419
|
+
_warn(f"capture endpoint {detail}")
|
|
3420
|
+
if not env or not agents:
|
|
3421
|
+
print(
|
|
3422
|
+
"For hook delivery, set SEDIMENT_OTLP_ENDPOINT and SEDIMENT_INGEST_TOKEN in the agent environment. For pi Edit observations, also set SEDIMENT_PI_TRANSCRIPTS=1."
|
|
3423
|
+
)
|
|
3424
|
+
print(f"{ok}transcript hook (SessionEnd): {_install_transcript_hook()}")
|
|
3425
|
+
print(
|
|
3426
|
+
f"{ok}codex transcript hook (SessionEnd): "
|
|
3427
|
+
f"{_install_codex_transcript_hook()}"
|
|
3428
|
+
)
|
|
3429
|
+
print(f"{ok}snapshot hook (PreToolUse): {_install_snapshot_hook()}")
|
|
3430
|
+
try:
|
|
3431
|
+
directory = (
|
|
3432
|
+
_delivery_directory()
|
|
3433
|
+
if agents and env
|
|
3434
|
+
else os.environ.get("SEDIMENT_DELIVERY_DIR")
|
|
3435
|
+
)
|
|
3436
|
+
except (OSError, UnicodeError, ValueError):
|
|
3437
|
+
_error("delivery enrollment could not be read")
|
|
3438
|
+
return 1
|
|
3439
|
+
verdict, check, detail = _doctor_delivery(directory)
|
|
3440
|
+
print(f"{verdict} {check}: {detail}")
|
|
3441
|
+
return 1 if verdict == DOCTOR_FAIL else 0
|
|
3442
|
+
|
|
3443
|
+
|
|
3444
|
+
def cmd_uninstall(repo: str, agents: bool) -> int:
|
|
3445
|
+
repo_path = Path(repo).resolve()
|
|
3446
|
+
if _git_dir(repo_path) is not None:
|
|
3447
|
+
hooks_dir = _hooks_dir(repo_path)
|
|
3448
|
+
if hooks_dir is not None:
|
|
3449
|
+
for name, _ in _REPO_HOOKS:
|
|
3450
|
+
_remove_hook_block(hooks_dir / name)
|
|
3451
|
+
# Remove only OUR value; other notes.rewriteRef entries survive.
|
|
3452
|
+
_git(
|
|
3453
|
+
["config", "--fixed-value", "--unset", "notes.rewriteRef", NOTES_REF],
|
|
3454
|
+
repo_path,
|
|
3455
|
+
)
|
|
3456
|
+
print(f"{ui.glyph('✓', 'phosphor')}removed git hooks from {hooks_dir}")
|
|
3457
|
+
if agents:
|
|
3458
|
+
ok = ui.glyph("✓", "phosphor")
|
|
3459
|
+
for path in (_claude_settings_path(), _codex_hooks_path()):
|
|
3460
|
+
if _remove_agent_entries(path):
|
|
3461
|
+
print(f"{ok}removed agent hook entries from {path}")
|
|
3462
|
+
if _remove_cursor_entries():
|
|
3463
|
+
print(f"{ok}removed cursor hook entries from {_cursor_hooks_path()}")
|
|
3464
|
+
if _remove_pi_extension():
|
|
3465
|
+
print(f"{ok}removed pi extension from {_pi_settings_path()}")
|
|
3466
|
+
for removed in _unwire_env():
|
|
3467
|
+
print(f"{ok}removed {removed}")
|
|
3468
|
+
if not _uninstall_codex_profiles():
|
|
3469
|
+
return 1
|
|
3470
|
+
return 0
|
|
3471
|
+
|
|
3472
|
+
|
|
3473
|
+
def _fleet_invocation(prefix: str, subcommand: str) -> str:
|
|
3474
|
+
# `python3` from PATH, not sys.executable: the bundle runs on machines
|
|
3475
|
+
# that are not this one.
|
|
3476
|
+
return f'python3 "{prefix}/sediment_attribution.py" {subcommand} || true'
|
|
3477
|
+
|
|
3478
|
+
|
|
3479
|
+
def _managed_claude_settings_path() -> Path:
|
|
3480
|
+
"""Claude Code's managed-settings.json location. The fleet bundle targets
|
|
3481
|
+
POSIX machines only (hooks invoke `python3`, prefix is a POSIX path);
|
|
3482
|
+
Native Windows capture is unsupported."""
|
|
3483
|
+
if sys.platform == "darwin":
|
|
3484
|
+
return Path("/Library/Application Support/ClaudeCode/managed-settings.json")
|
|
3485
|
+
return Path("/etc/claude-code/managed-settings.json")
|
|
3486
|
+
|
|
3487
|
+
|
|
3488
|
+
def _claude_settings_candidates() -> list[Path]:
|
|
3489
|
+
"""Every file a Claude Code hook entry can legitimately live in, in the
|
|
3490
|
+
order Claude Code layers them: the MDM-managed file (what
|
|
3491
|
+
``install --fleet --apply`` writes) then the per-user one (what
|
|
3492
|
+
``install`` writes). Either carrying our entry means sessions get marked,
|
|
3493
|
+
so ``doctor`` must look in both before calling a machine unhooked."""
|
|
3494
|
+
return [_managed_claude_settings_path(), _claude_settings_path()]
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
def _emit_fleet_bundle(out: Path, prefix: str) -> bool:
|
|
3498
|
+
"""Write the MDM bundle into ``out``; hooks reference the stamper at
|
|
3499
|
+
``prefix``. Re-runs regenerate in place (the template hooks go through
|
|
3500
|
+
``_install_hook_block``, so foreign content in a pre-existing hook is
|
|
3501
|
+
preserved and a non-sh hook is refused, same as the per-repo installer).
|
|
3502
|
+
"""
|
|
3503
|
+
hooks_dir = out / "git-template" / "hooks"
|
|
3504
|
+
ok = all(
|
|
3505
|
+
# a realized list, not a generator: all() would stop consuming a
|
|
3506
|
+
# generator at the first False and skip the second hook's install
|
|
3507
|
+
[
|
|
3508
|
+
_install_hook_block(hooks_dir / name, _fleet_invocation(prefix, sub))
|
|
3509
|
+
for name, sub in _REPO_HOOKS
|
|
3510
|
+
]
|
|
3511
|
+
)
|
|
3512
|
+
(out / "sediment_attribution.py").write_bytes(Path(__file__).resolve().read_bytes())
|
|
3513
|
+
for name in ("transcript", "delivery"):
|
|
3514
|
+
(out / f"sediment_{name}.py").write_bytes(
|
|
3515
|
+
_capture_client_path(name).read_bytes()
|
|
3516
|
+
)
|
|
3517
|
+
(out / "gitconfig").write_text(
|
|
3518
|
+
f"[init]\n\ttemplateDir = {prefix}/git-template\n"
|
|
3519
|
+
f"[notes]\n\trewriteRef = {NOTES_REF}\n",
|
|
3520
|
+
encoding="utf-8",
|
|
3521
|
+
)
|
|
3522
|
+
fragments = {
|
|
3523
|
+
"claude-managed-settings.json": _claude_hook_block(
|
|
3524
|
+
_fleet_invocation(prefix, "mark --tool claude-code")
|
|
3525
|
+
),
|
|
3526
|
+
"codex-hooks.json": _codex_hook_block(
|
|
3527
|
+
_fleet_invocation(prefix, "mark --tool codex")
|
|
3528
|
+
),
|
|
3529
|
+
}
|
|
3530
|
+
for name, block in fragments.items():
|
|
3531
|
+
# Same writer --apply uses, so the emitted fragment bytes can never
|
|
3532
|
+
# diverge from what the installer itself would write.
|
|
3533
|
+
_write_json_atomic(out / name, {"hooks": {"PostToolUse": [block]}})
|
|
3534
|
+
return ok
|
|
3535
|
+
|
|
3536
|
+
|
|
3537
|
+
def _is_our_template_dir(template_dir: str) -> bool:
|
|
3538
|
+
"""True when an existing init.templateDir is a sediment-managed template
|
|
3539
|
+
(its post-commit carries our marker block) — safe to repoint on a prefix
|
|
3540
|
+
migration. Anything else, including a deleted dir, is treated as foreign.
|
|
3541
|
+
"""
|
|
3542
|
+
try:
|
|
3543
|
+
hook = Path(template_dir) / "hooks" / "post-commit"
|
|
3544
|
+
return HOOK_BLOCK_BEGIN in hook.read_text(encoding="utf-8")
|
|
3545
|
+
except (OSError, UnicodeDecodeError):
|
|
3546
|
+
return False
|
|
3547
|
+
|
|
3548
|
+
|
|
3549
|
+
def _system_config(*args: str) -> bool:
|
|
3550
|
+
if _git(["config", "--system", *args]) is not None:
|
|
3551
|
+
return True
|
|
3552
|
+
_error("could not write the system gitconfig (re-run with privileges?)")
|
|
3553
|
+
return False
|
|
3554
|
+
|
|
3555
|
+
|
|
3556
|
+
def cmd_install_fleet(out: str | None, apply: bool, prefix: str | None) -> int:
|
|
3557
|
+
"""Emit the fleet bundle (default), or ``--apply`` it to this machine:
|
|
3558
|
+
bundle into ``prefix`` + system gitconfig keys + Claude managed settings.
|
|
3559
|
+
|
|
3560
|
+
Unlike the hook subcommands this is admin-facing, so failures are loud
|
|
3561
|
+
(non-zero exit) — but nothing it doesn't own is ever overwritten: a
|
|
3562
|
+
foreign ``init.templateDir`` or a managed-settings file that doesn't
|
|
3563
|
+
parse is left untouched with a warning.
|
|
3564
|
+
"""
|
|
3565
|
+
prefix = (prefix or "/opt/sediment").rstrip("/")
|
|
3566
|
+
# The prefix lands inside sh double quotes and a gitconfig value on every
|
|
3567
|
+
# fleet machine: relative paths silently break stamping fleet-wide (hooks
|
|
3568
|
+
# are best-effort), and `"`/`\`/`$`/backtick corrupt the quoting or the
|
|
3569
|
+
# gitconfig escape syntax. POSIX-absolute only.
|
|
3570
|
+
if not prefix.startswith("/") or any(c in prefix for c in '"\\$`\n'):
|
|
3571
|
+
_error(
|
|
3572
|
+
'--prefix must be an absolute POSIX path without ", \\, $, '
|
|
3573
|
+
"backticks, or newlines"
|
|
3574
|
+
)
|
|
3575
|
+
return 2
|
|
3576
|
+
out_dir = Path(prefix) if apply else Path(out or "sediment-fleet")
|
|
3577
|
+
try:
|
|
3578
|
+
ok = _emit_fleet_bundle(out_dir, prefix)
|
|
3579
|
+
except (OSError, RuntimeError) as exc:
|
|
3580
|
+
hint = " (re-run with privileges?)" if apply else ""
|
|
3581
|
+
_error(f"could not write the fleet bundle to {out_dir}: {exc}{hint}")
|
|
3582
|
+
return 1
|
|
3583
|
+
print(f"{ui.glyph('✓', 'phosphor')}fleet bundle written to {out_dir}")
|
|
3584
|
+
if not ok:
|
|
3585
|
+
# Don't hand out an incomplete bundle — and never wire system config
|
|
3586
|
+
# at a template dir whose hooks we were refused from installing into
|
|
3587
|
+
# (that would activate the foreign hook on every future clone).
|
|
3588
|
+
_error("bundle incomplete — fix the hook files warned about above and re-run")
|
|
3589
|
+
return 1
|
|
3590
|
+
if not apply:
|
|
3591
|
+
print(
|
|
3592
|
+
"distribute the bundle via MDM, or re-run with --apply on a "
|
|
3593
|
+
"machine to provision it directly"
|
|
3594
|
+
)
|
|
3595
|
+
return 0
|
|
3596
|
+
|
|
3597
|
+
gitconfig_ok = True
|
|
3598
|
+
template_dir = f"{prefix}/git-template"
|
|
3599
|
+
existing = _git(["config", "--system", "--get", "init.templateDir"])
|
|
3600
|
+
if existing is not None and existing != template_dir:
|
|
3601
|
+
if _is_our_template_dir(existing):
|
|
3602
|
+
# Prefix migration: the old value is our own template — repoint.
|
|
3603
|
+
gitconfig_ok = _system_config("init.templateDir", template_dir)
|
|
3604
|
+
else:
|
|
3605
|
+
_warn(
|
|
3606
|
+
f"system init.templateDir is already set to "
|
|
3607
|
+
f"{existing!r}; not overwritten — merge {out_dir}/gitconfig "
|
|
3608
|
+
"into it manually"
|
|
3609
|
+
)
|
|
3610
|
+
gitconfig_ok = False
|
|
3611
|
+
elif existing is None:
|
|
3612
|
+
gitconfig_ok = _system_config("init.templateDir", template_dir)
|
|
3613
|
+
rewrite = _git(["config", "--system", "--get-all", "notes.rewriteRef"]) or ""
|
|
3614
|
+
if NOTES_REF not in rewrite.splitlines():
|
|
3615
|
+
gitconfig_ok = (
|
|
3616
|
+
_system_config("--add", "notes.rewriteRef", NOTES_REF) and gitconfig_ok
|
|
3617
|
+
)
|
|
3618
|
+
managed = _managed_claude_settings_path()
|
|
3619
|
+
block = _claude_hook_block(_fleet_invocation(prefix, "mark --tool claude-code"))
|
|
3620
|
+
try:
|
|
3621
|
+
status = _install_agent_entry(managed, block, "claude-code managed")
|
|
3622
|
+
except OSError as exc:
|
|
3623
|
+
_error(f"could not write {managed}: {exc} (re-run with privileges?)")
|
|
3624
|
+
status = "failed"
|
|
3625
|
+
print(
|
|
3626
|
+
f"system gitconfig {'applied' if gitconfig_ok else 'FAILED (see above)'}; "
|
|
3627
|
+
f"claude-code managed settings {status}; codex has no system-level hooks "
|
|
3628
|
+
f"file — distribute {out_dir}/codex-hooks.json into each user's "
|
|
3629
|
+
"~/.codex/hooks.json"
|
|
3630
|
+
)
|
|
3631
|
+
return 0 if gitconfig_ok and status not in ("skipped", "failed") else 1
|
|
3632
|
+
|
|
3633
|
+
|
|
3634
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
3635
|
+
"""The attribution verbs' argparse tree, extracted so the generated CLI
|
|
3636
|
+
reference can walk it — these flags live nowhere else, since
|
|
3637
|
+
``cli.py`` carries help stubs only."""
|
|
3638
|
+
parser = argparse.ArgumentParser(prog="sediment", description=__doc__)
|
|
3639
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
3640
|
+
|
|
3641
|
+
sub.add_parser("cursor-hook", help="translate one native Cursor hook event")
|
|
3642
|
+
|
|
3643
|
+
p_mark = sub.add_parser("mark", help="record a session marker (agent hook)")
|
|
3644
|
+
# Registration twin of AgentHarness (models.py): a harness's shim can
|
|
3645
|
+
# only mark once its tool value is accepted here. The script is
|
|
3646
|
+
# stdlib-only by design, so the list is duplicated, not imported.
|
|
3647
|
+
p_mark.add_argument(
|
|
3648
|
+
"--tool", required=True, choices=["claude-code", "codex", "cursor", "pi"]
|
|
3649
|
+
)
|
|
3650
|
+
|
|
3651
|
+
sub.add_parser("stamp", help="write the attribution note on HEAD (post-commit)")
|
|
3652
|
+
|
|
3653
|
+
p_squash = sub.add_parser(
|
|
3654
|
+
"union-squash-notes",
|
|
3655
|
+
help="fold squashed commits' notes into local markers (prepare-commit-msg)",
|
|
3656
|
+
)
|
|
3657
|
+
p_squash.add_argument("msg_file")
|
|
3658
|
+
p_squash.add_argument("source")
|
|
3659
|
+
p_squash.add_argument("commit_sha", nargs="?", default=None)
|
|
3660
|
+
|
|
3661
|
+
p_push = sub.add_parser(
|
|
3662
|
+
"push-notes", help="reconcile and push the notes ref (pre-push)"
|
|
3663
|
+
)
|
|
3664
|
+
p_push.add_argument("remote")
|
|
3665
|
+
|
|
3666
|
+
p_repair = sub.add_parser(
|
|
3667
|
+
"repair-notes",
|
|
3668
|
+
help="reconcile the notes ref with a remote and push (operator fix)",
|
|
3669
|
+
)
|
|
3670
|
+
p_repair.add_argument("remote", nargs="?", default="origin")
|
|
3671
|
+
|
|
3672
|
+
p_doctor = sub.add_parser(
|
|
3673
|
+
"doctor",
|
|
3674
|
+
help="check capture configuration or verify one captured Session",
|
|
3675
|
+
)
|
|
3676
|
+
p_doctor.add_argument(
|
|
3677
|
+
"repos",
|
|
3678
|
+
nargs="*",
|
|
3679
|
+
metavar="REPO",
|
|
3680
|
+
help="also run the per-repo checks on each REPO (default: agent and "
|
|
3681
|
+
"machine-level checks only)",
|
|
3682
|
+
)
|
|
3683
|
+
p_doctor.add_argument(
|
|
3684
|
+
"--fetch",
|
|
3685
|
+
action="store_true",
|
|
3686
|
+
help="allow one fetch into the notes tracking ref, so a notes ref "
|
|
3687
|
+
"whose remote tip is not a local object can be classified as behind "
|
|
3688
|
+
"vs diverged; without it doctor writes nothing at all",
|
|
3689
|
+
)
|
|
3690
|
+
p_doctor.add_argument(
|
|
3691
|
+
"--agent",
|
|
3692
|
+
choices=("cursor", "codex", "pi"),
|
|
3693
|
+
help="verify this harness's installation and captured Session evidence",
|
|
3694
|
+
)
|
|
3695
|
+
p_doctor.add_argument(
|
|
3696
|
+
"--session-id", help="verify this exact Session with --agent and one REPO"
|
|
3697
|
+
)
|
|
3698
|
+
p_doctor.add_argument(
|
|
3699
|
+
"--transcripts",
|
|
3700
|
+
action="store_true",
|
|
3701
|
+
help="also require this harness's Edit observation and transcript opt-in",
|
|
3702
|
+
)
|
|
3703
|
+
p_doctor.add_argument(
|
|
3704
|
+
"--inference-calls",
|
|
3705
|
+
action="store_true",
|
|
3706
|
+
help="also require an Inference call in the selected Session",
|
|
3707
|
+
)
|
|
3708
|
+
|
|
3709
|
+
p_install = sub.add_parser("install", help="install hooks for a repo")
|
|
3710
|
+
p_install.add_argument("repo", nargs="?", default=None)
|
|
3711
|
+
p_install.add_argument(
|
|
3712
|
+
"--no-agents",
|
|
3713
|
+
action="store_true",
|
|
3714
|
+
help="skip user-level hooks for Claude Code, Codex, Cursor, and pi",
|
|
3715
|
+
)
|
|
3716
|
+
p_install.add_argument(
|
|
3717
|
+
"--transcripts",
|
|
3718
|
+
action="store_true",
|
|
3719
|
+
help="opt in to the SessionEnd transcript extractor: ships "
|
|
3720
|
+
"applied edit text and observed file text to the ingest endpoint, "
|
|
3721
|
+
"plus the PreToolUse snapshot hook whose line hashes let it report "
|
|
3722
|
+
"how many lines something other than the agent changed; also enable "
|
|
3723
|
+
"pi Edit observations in the generated environment",
|
|
3724
|
+
)
|
|
3725
|
+
p_install.add_argument(
|
|
3726
|
+
"--no-env",
|
|
3727
|
+
action="store_true",
|
|
3728
|
+
help="skip writing the agent telemetry env files; by default "
|
|
3729
|
+
"install generates them from the `sediment login` config",
|
|
3730
|
+
)
|
|
3731
|
+
p_install.add_argument(
|
|
3732
|
+
"--user-id",
|
|
3733
|
+
default=None,
|
|
3734
|
+
help="stamp OTEL_RESOURCE_ATTRIBUTES=user.id=<value> into the env "
|
|
3735
|
+
"files (per-developer attribution)",
|
|
3736
|
+
)
|
|
3737
|
+
p_install.add_argument(
|
|
3738
|
+
"--codex-profile",
|
|
3739
|
+
metavar="NAME",
|
|
3740
|
+
help="write a Codex telemetry profile with resolved login credentials (0600); preserve its model and unrelated settings",
|
|
3741
|
+
)
|
|
3742
|
+
p_install.add_argument(
|
|
3743
|
+
"--gateway-url",
|
|
3744
|
+
default=None,
|
|
3745
|
+
help="also wire ANTHROPIC_BASE_URL to this LLM gateway",
|
|
3746
|
+
)
|
|
3747
|
+
p_install.add_argument(
|
|
3748
|
+
"--gateway-key",
|
|
3749
|
+
default=None,
|
|
3750
|
+
help="also wire ANTHROPIC_AUTH_TOKEN and SEDIMENT_GATEWAY_KEY "
|
|
3751
|
+
"(the key agents present to the gateway)",
|
|
3752
|
+
)
|
|
3753
|
+
p_install.add_argument(
|
|
3754
|
+
"--fleet",
|
|
3755
|
+
action="store_true",
|
|
3756
|
+
help="emit the machine-wide MDM bundle instead of a per-repo install",
|
|
3757
|
+
)
|
|
3758
|
+
fleet_target = p_install.add_mutually_exclusive_group()
|
|
3759
|
+
fleet_target.add_argument(
|
|
3760
|
+
"--out",
|
|
3761
|
+
metavar="DIR",
|
|
3762
|
+
default=None,
|
|
3763
|
+
help="fleet: emit the bundle to DIR (default: ./sediment-fleet)",
|
|
3764
|
+
)
|
|
3765
|
+
fleet_target.add_argument(
|
|
3766
|
+
"--apply",
|
|
3767
|
+
action="store_true",
|
|
3768
|
+
help="fleet: provision this machine directly — bundle into --prefix, "
|
|
3769
|
+
"system gitconfig, Claude Code managed settings (needs privileges)",
|
|
3770
|
+
)
|
|
3771
|
+
p_install.add_argument(
|
|
3772
|
+
"--prefix",
|
|
3773
|
+
default=None,
|
|
3774
|
+
help="fleet: absolute POSIX path where MDM installs the bundle; hooks "
|
|
3775
|
+
"reference the stamper there (default: /opt/sediment)",
|
|
3776
|
+
)
|
|
3777
|
+
|
|
3778
|
+
p_uninstall = sub.add_parser("uninstall", help="remove hooks from a repo")
|
|
3779
|
+
p_uninstall.add_argument("repo", nargs="?", default=".")
|
|
3780
|
+
p_uninstall.add_argument(
|
|
3781
|
+
"--agents",
|
|
3782
|
+
action="store_true",
|
|
3783
|
+
help="also remove user-level agent hooks, generated env, and managed Codex telemetry",
|
|
3784
|
+
)
|
|
3785
|
+
|
|
3786
|
+
return parser
|
|
3787
|
+
|
|
3788
|
+
|
|
3789
|
+
def main(argv: list[str] | None = None) -> int:
|
|
3790
|
+
args = build_parser().parse_args(argv)
|
|
3791
|
+
if args.command == "install" and os.name == "nt":
|
|
3792
|
+
_error("Windows capture is unsupported; run sediment install on macOS or Linux")
|
|
3793
|
+
return 1
|
|
3794
|
+
if args.command == "cursor-hook":
|
|
3795
|
+
return cmd_cursor_hook()
|
|
3796
|
+
if args.command == "mark":
|
|
3797
|
+
return cmd_mark(args.tool)
|
|
3798
|
+
if args.command == "stamp":
|
|
3799
|
+
return cmd_stamp()
|
|
3800
|
+
if args.command == "union-squash-notes":
|
|
3801
|
+
return cmd_union_squash_notes(args.msg_file, args.source)
|
|
3802
|
+
if args.command == "push-notes":
|
|
3803
|
+
return cmd_push_notes(args.remote)
|
|
3804
|
+
if args.command == "repair-notes":
|
|
3805
|
+
return cmd_repair_notes(args.remote)
|
|
3806
|
+
if args.command == "doctor":
|
|
3807
|
+
verification = (
|
|
3808
|
+
args.agent is not None
|
|
3809
|
+
or args.session_id is not None
|
|
3810
|
+
or args.transcripts
|
|
3811
|
+
or args.inference_calls
|
|
3812
|
+
)
|
|
3813
|
+
if verification and (
|
|
3814
|
+
args.agent is None
|
|
3815
|
+
or not args.session_id
|
|
3816
|
+
or not args.session_id.strip()
|
|
3817
|
+
or any(ord(char) < 32 for char in args.session_id)
|
|
3818
|
+
or len(args.repos) != 1
|
|
3819
|
+
):
|
|
3820
|
+
_error(
|
|
3821
|
+
"verification requires one REPO, --agent, and a nonempty --session-id"
|
|
3822
|
+
)
|
|
3823
|
+
return 2
|
|
3824
|
+
return cmd_doctor(
|
|
3825
|
+
args.repos,
|
|
3826
|
+
args.fetch,
|
|
3827
|
+
args.agent,
|
|
3828
|
+
args.session_id,
|
|
3829
|
+
args.transcripts,
|
|
3830
|
+
args.inference_calls,
|
|
3831
|
+
)
|
|
3832
|
+
if args.command == "install":
|
|
3833
|
+
# Reject flag mixes that would otherwise be silently ignored — an
|
|
3834
|
+
# admin who typos the mode must not get a different install with
|
|
3835
|
+
# exit 0.
|
|
3836
|
+
per_user_flags = (
|
|
3837
|
+
args.repo is not None
|
|
3838
|
+
or args.no_agents
|
|
3839
|
+
or args.transcripts
|
|
3840
|
+
or args.no_env
|
|
3841
|
+
or args.user_id is not None
|
|
3842
|
+
or args.gateway_url is not None
|
|
3843
|
+
or args.gateway_key is not None
|
|
3844
|
+
or args.codex_profile is not None
|
|
3845
|
+
)
|
|
3846
|
+
if args.fleet:
|
|
3847
|
+
if per_user_flags:
|
|
3848
|
+
_error(
|
|
3849
|
+
"REPO, --no-agents, --transcripts, --codex-profile, and the env "
|
|
3850
|
+
"flags (--no-env/--user-id/--gateway-*) do not apply "
|
|
3851
|
+
"to --fleet"
|
|
3852
|
+
)
|
|
3853
|
+
return 2
|
|
3854
|
+
return cmd_install_fleet(args.out, args.apply, args.prefix)
|
|
3855
|
+
if args.out is not None or args.apply or args.prefix is not None:
|
|
3856
|
+
_error("--out/--apply/--prefix require --fleet")
|
|
3857
|
+
return 2
|
|
3858
|
+
if args.codex_profile is not None and not re.fullmatch(
|
|
3859
|
+
r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", args.codex_profile
|
|
3860
|
+
):
|
|
3861
|
+
_error(
|
|
3862
|
+
"profile name must use 1–64 letters, digits, underscores, or hyphens and start with a letter or digit"
|
|
3863
|
+
)
|
|
3864
|
+
return 2
|
|
3865
|
+
return cmd_install(
|
|
3866
|
+
args.repo or ".",
|
|
3867
|
+
agents=not args.no_agents,
|
|
3868
|
+
transcripts=args.transcripts,
|
|
3869
|
+
env=not args.no_env,
|
|
3870
|
+
user_id=args.user_id,
|
|
3871
|
+
gateway_url=args.gateway_url,
|
|
3872
|
+
gateway_key=args.gateway_key,
|
|
3873
|
+
codex_profile=args.codex_profile,
|
|
3874
|
+
)
|
|
3875
|
+
if args.command == "uninstall":
|
|
3876
|
+
return cmd_uninstall(args.repo, agents=args.agents)
|
|
3877
|
+
return 2 # unreachable
|
|
3878
|
+
|
|
3879
|
+
|
|
3880
|
+
if __name__ == "__main__":
|
|
3881
|
+
sys.exit(main())
|