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.
@@ -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())