sediment-cli 0.6.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/PKG-INFO +5 -5
  2. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/pyproject.toml +5 -5
  3. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/README.md +1 -1
  4. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/package.json +2 -2
  5. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/agent_guide.txt +5 -4
  6. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/attribution.py +123 -27
  7. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/cli.py +35 -14
  8. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/transcript.py +37 -0
  9. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_help.py +50 -1
  10. sediment_cli-0.6.1/tests/testdata/help/repair-notes.txt +14 -0
  11. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/sediment.txt +4 -1
  12. sediment_cli-0.6.1/tests/testdata/help/stamp.txt +12 -0
  13. sediment_cli-0.6.1/tests/testdata/help/transcript.txt +20 -0
  14. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/uninstall.txt +2 -1
  15. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/.gitignore +0 -0
  16. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/LICENSE +0 -0
  17. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/README.md +0 -0
  18. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/hatch_build.py +0 -0
  19. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/__init__.py +0 -0
  20. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/LICENSE +0 -0
  21. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/index.ts +0 -0
  22. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/lib/contract.ts +0 -0
  23. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/lib/process.ts +0 -0
  24. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/lib/provider.ts +0 -0
  25. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/lib/register.ts +0 -0
  26. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/_pi/lib/retrieval.ts +0 -0
  27. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/client.py +0 -0
  28. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/delivery.py +0 -0
  29. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/evidence.py +0 -0
  30. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/local_postgres.py +0 -0
  31. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/sediment_cli/ui.py +0 -0
  32. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/conftest.py +0 -0
  33. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_agent_guide.py +0 -0
  34. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli.py +0 -0
  35. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_capture_authority.py +0 -0
  36. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_db.py +0 -0
  37. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_evidence.py +0 -0
  38. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_evidence_integration.py +0 -0
  39. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_libpq_unavailable.py +0 -0
  40. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_remote.py +0 -0
  41. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_cli_ui.py +0 -0
  42. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_consumer_profile_cli.py +0 -0
  43. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_delivery_transport.py +0 -0
  44. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_demo_verb.py +0 -0
  45. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_e2e_quickstart.py +0 -0
  46. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_installed_wheel.py +0 -0
  47. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_local_postgres.py +0 -0
  48. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/test_server_migration.py +0 -0
  49. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/commit.txt +0 -0
  50. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/db-check.txt +0 -0
  51. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/db-provision.txt +0 -0
  52. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/db-status.txt +0 -0
  53. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/db-upgrade.txt +0 -0
  54. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/db.txt +0 -0
  55. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/delivery-enqueue.txt +0 -0
  56. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/delivery-replay.txt +0 -0
  57. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/delivery-status.txt +0 -0
  58. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/delivery.txt +0 -0
  59. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/demo.txt +0 -0
  60. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/derive.txt +0 -0
  61. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/doctor.txt +0 -0
  62. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/evidence-fetch.txt +0 -0
  63. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/evidence-inspect.txt +0 -0
  64. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/evidence-inventory.txt +0 -0
  65. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/evidence.txt +0 -0
  66. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export-diff-sft.txt +0 -0
  67. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export-dpo.txt +0 -0
  68. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export-recovery.txt +0 -0
  69. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export-rlvr.txt +0 -0
  70. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export-sft.txt +0 -0
  71. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/export.txt +0 -0
  72. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/facts.txt +0 -0
  73. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/guide.txt +0 -0
  74. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/install.txt +0 -0
  75. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/login.txt +0 -0
  76. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/logout.txt +0 -0
  77. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/mirror-gc.txt +0 -0
  78. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/quarantine-inference-calls.txt +0 -0
  79. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/quarantine-log.txt +0 -0
  80. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/quarantine.txt +0 -0
  81. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/release.txt +0 -0
  82. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-abandonment.txt +0 -0
  83. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-attribution-share.txt +0 -0
  84. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-dataset-diagnostics.txt +0 -0
  85. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-label-confidence-inspection.txt +0 -0
  86. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-lifecycle.txt +0 -0
  87. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-merge-retention.txt +0 -0
  88. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-model.txt +0 -0
  89. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-precision.txt +0 -0
  90. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report-recovery-yield.txt +0 -0
  91. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/report.txt +0 -0
  92. {sediment_cli-0.6.0 → sediment_cli-0.6.1}/tests/testdata/help/server.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: A self-hosted evidence store for coding agents: the sediment CLI and local server
5
5
  Project-URL: Source, https://github.com/sediment-ai/sediment
6
6
  Project-URL: Documentation, https://docs.sediment.so
@@ -10,10 +10,10 @@ License-Expression: AGPL-3.0-or-later
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
12
  Requires-Dist: httpx>=0.27
13
- Requires-Dist: sediment-api==0.6.0
14
- Requires-Dist: sediment-core==0.6.0
15
- Requires-Dist: sediment-derive==0.6.0
16
- Requires-Dist: sediment-export==0.6.0
13
+ Requires-Dist: sediment-api==0.6.1
14
+ Requires-Dist: sediment-core==0.6.1
15
+ Requires-Dist: sediment-derive==0.6.1
16
+ Requires-Dist: sediment-export==0.6.1
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # sediment-cli
@@ -5,7 +5,7 @@
5
5
  # Depends on sediment-api deliberately: the operator verbs are DB-local by
6
6
  # design (ADR 0001) and `server`/reports/mirror-gc forward into it.
7
7
  name = "sediment-cli"
8
- version = "0.6.0"
8
+ version = "0.6.1"
9
9
  description = "A self-hosted evidence store for coding agents: the sediment CLI and local server"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.12"
@@ -15,10 +15,10 @@ license-files = ["LICENSE"]
15
15
  # so an install never mixes member versions (the release workflow asserts
16
16
  # the versions agree before publishing).
17
17
  dependencies = [
18
- "sediment-core==0.6.0",
19
- "sediment-derive==0.6.0",
20
- "sediment-export==0.6.0",
21
- "sediment-api==0.6.0",
18
+ "sediment-core==0.6.1",
19
+ "sediment-derive==0.6.1",
20
+ "sediment-export==0.6.1",
21
+ "sediment-api==0.6.1",
22
22
  "httpx>=0.27",
23
23
  ]
24
24
 
@@ -195,7 +195,7 @@ delivery changes also trigger this workflow. To exercise the same tests locally,
195
195
  set `SEDIMENT_PI_TEST_PYTHON` to the installed environment's Python executable
196
196
  and `SEDIMENT_PI_TEST_INSTALLED_BIN` to its `bin` directory before `npm test`.
197
197
 
198
- The lockfile pins pi 0.87.1 (`@earendil-works/pi-coding-agent`) as a development
198
+ The lockfile pins pi 1.1.0 (`@earendil-works/pi-coding-agent`) as a development
199
199
  dependency. The native retrieval test exercises its extension registration,
200
200
  argument validation, agent loop, and model-visible tool result with a scripted
201
201
  model stream. It makes no paid inference request and downloads no package during
@@ -13,8 +13,8 @@
13
13
  "typecheck": "tsc --noEmit"
14
14
  },
15
15
  "devDependencies": {
16
- "@earendil-works/pi-coding-agent": "0.87.1",
17
- "@types/node": "^24.19.0",
16
+ "@earendil-works/pi-coding-agent": "1.1.0",
17
+ "@types/node": "^24.19.2",
18
18
  "typescript": "^7.0.2"
19
19
  }
20
20
  }
@@ -124,10 +124,11 @@ Follow [Secure a deployment](security.md) when handling secrets.
124
124
 
125
125
  Capture can leave the developer machine in these forms:
126
126
 
127
- - The pre-push hook sends the whole notes ref to each push remote, including
128
- forks, even during `git push --dry-run`. Notes contain commit SHAs, agent
129
- names, Session identifiers, and timestamps, including other branches' notes.
130
- They don't contain prompts, diffs, file paths, model names, or hostnames.
127
+ - The pre-push hook sends the whole notes ref to `origin` only, even during
128
+ `git push --dry-run`. A push to any other remote, such as a fork, sends and
129
+ fetches no notes. Notes contain commit SHAs, agent names, Session
130
+ identifiers, and timestamps, including other branches' notes. They don't
131
+ contain prompts, diffs, file paths, model names, or hostnames.
131
132
  - Agent telemetry goes to the Sediment server. Claude Code tool details can
132
133
  include paths, shell commands, edit text, and the Claude account email.
133
134
  Codex decisions can include patch code and tool arguments; Sediment removes
@@ -33,16 +33,19 @@ Stdlib only, no daemon. Nine subcommands:
33
33
  and unions their already-written notes back into the
34
34
  local marker file, so the post-commit ``stamp`` step
35
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``
36
+ push-notes REMOTE pre-push hook: when the push goes to ``origin``,
37
+ reconciles the notes ref with it (fetch +
38
+ ``cat_sort_uniq`` union merge), then pushes it alongside
39
+ the push, retrying once if the remote moved; a final
40
+ failure is logged to ``~/.sediment/attribution.log``.
41
+ A push to any other remote, such as a fork, syncs
42
+ nothing and logs ``notes-push-skipped``
41
43
  repair-notes [REMOTE]
42
44
  operator command (not a hook, NOT best-effort): the same
43
45
  reconcile + push on demand, for a machine whose notes
44
46
  ref already diverged, or a fresh machine adopting
45
- the remote's notes; exits non-zero on failure
47
+ the remote's notes; exits non-zero on failure, and
48
+ refuses a REMOTE that is not ``origin``
46
49
  doctor [REPO ...] operator command: one-shot health check of everything
47
50
  attribution needs on this machine — agent hook entries,
48
51
  the fleet git template, the attribution log's recorded
@@ -748,6 +751,7 @@ _CAPTURE_FAILURE_REASONS = (
748
751
  "stamp_cleanup_unconfirmed",
749
752
  "notes_reconcile_busy",
750
753
  "notes_reconcile_failed",
754
+ "notes_remote_not_origin",
751
755
  "notes_lock_failed",
752
756
  )
753
757
 
@@ -1148,6 +1152,25 @@ def cmd_union_squash_notes(msg_file: str, source: str) -> int:
1148
1152
  # Where the remote's notes land during reconcile. A plain tracking ref, so a
1149
1153
  # failed merge can never damage the real local ref.
1150
1154
  NOTES_REMOTE_TRACKING_REF = "refs/notes/sediment-remote"
1155
+ NOTES_PUSH_TIMEOUT_SECONDS = 120
1156
+
1157
+
1158
+ def _is_notes_remote(remote: str) -> bool:
1159
+ """True when ``remote`` is this repository's ``origin``, by name or URL.
1160
+
1161
+ Git hands ``pre-push`` whatever the developer pushes to. Reconciling
1162
+ with a fork would union-merge notes its owner wrote into the ref the
1163
+ server-side Attribution Derivation reads, and publish every local note
1164
+ to that fork.
1165
+ """
1166
+ # ponytail: origin is the one notes remote, as in doctor and the owner
1167
+ # allowlist; a git config key if a shared remote ever has another name.
1168
+ origin = _git(["remote", "get-url", "origin"])
1169
+ if not origin:
1170
+ return False
1171
+ # Expands a remote name or a URL alike, applying url.<base>.insteadOf.
1172
+ url = _git(["ls-remote", "--get-url", remote]) or remote
1173
+ return _normalize_remote(url) == _normalize_remote(origin)
1151
1174
 
1152
1175
 
1153
1176
  def _reconcile_notes(remote: str) -> bool:
@@ -1176,6 +1199,11 @@ def _reconcile_notes(remote: str) -> bool:
1176
1199
 
1177
1200
  def _reconcile_notes_locked(remote: str) -> bool:
1178
1201
  """The caller owns the common notes mutex, including the tracking-ref fetch."""
1202
+ # The one guard every notes sync passes. The refusal makes
1203
+ # ``_reconcile_notes`` return False, which also stops
1204
+ # ``_reconciled_push`` before its push.
1205
+ if not _is_notes_remote(remote):
1206
+ raise _CaptureFailure("notes_remote_not_origin")
1179
1207
  if _git(["fetch", remote, f"+{NOTES_REF}:{NOTES_REMOTE_TRACKING_REF}"]) is None:
1180
1208
  return True # remote unreachable or has no notes ref yet — the push decides
1181
1209
  if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
@@ -1219,18 +1247,49 @@ def _reconciled_push(remote: str) -> subprocess.CompletedProcess:
1219
1247
  return subprocess.CompletedProcess(
1220
1248
  ["git", "push"], 1, "", "notes_reconcile_incomplete"
1221
1249
  )
1222
- result = subprocess.run(
1223
- ["git", "push", remote, f"{NOTES_REF}:{NOTES_REF}"],
1224
- capture_output=True,
1225
- text=True,
1226
- timeout=120,
1227
- env=env,
1228
- )
1250
+ try:
1251
+ result = subprocess.run(
1252
+ ["git", "push", remote, f"{NOTES_REF}:{NOTES_REF}"],
1253
+ capture_output=True,
1254
+ text=True,
1255
+ timeout=NOTES_PUSH_TIMEOUT_SECONDS,
1256
+ env=env,
1257
+ )
1258
+ except subprocess.TimeoutExpired:
1259
+ # Never surfaced: the exception text holds git's arguments, and
1260
+ # ``remote`` can be a URL with credentials.
1261
+ return subprocess.CompletedProcess(
1262
+ ["git", "push"],
1263
+ 1,
1264
+ "",
1265
+ f"git push timed out after {NOTES_PUSH_TIMEOUT_SECONDS}s",
1266
+ )
1229
1267
  if result.returncode == 0:
1230
1268
  break
1231
1269
  return result
1232
1270
 
1233
1271
 
1272
+ # ponytail: everything from "://" to the last "@" counts as userinfo, so an
1273
+ # "@" in a URL path, or later in the text, over-strips: ``https://host/a@b``
1274
+ # shows as ``https://b``. That is the safe side: a pattern that stops at "/"
1275
+ # or at whitespace passes ``scheme://user:pa/ss@host`` and
1276
+ # ``scheme://user:pass word@host`` through as typed. Parse the authority if
1277
+ # a hidden path segment ever matters.
1278
+ _URL_USERINFO_RE = re.compile(r"://.*@", re.DOTALL)
1279
+
1280
+
1281
+ def _strip_userinfo(text: str) -> str:
1282
+ """``text`` with the userinfo cut from the URL in it.
1283
+
1284
+ Git hands ``pre-push`` a pushed URL exactly as typed, and Git before
1285
+ 2.27 repeats a push URL unchanged in its error line, so the remote
1286
+ argument and the push diagnostic can each carry a credential. A remote
1287
+ name, a bare path, and an scp-like ``user@host:path`` hold no ``://``
1288
+ and pass through.
1289
+ """
1290
+ return _URL_USERINFO_RE.sub("://", text)
1291
+
1292
+
1234
1293
  def _push_failure_detail(result: subprocess.CompletedProcess) -> str:
1235
1294
  """Last stderr line, else last stdout line, else the exit code.
1236
1295
 
@@ -1239,10 +1298,10 @@ def _push_failure_detail(result: subprocess.CompletedProcess) -> str:
1239
1298
  """
1240
1299
  stderr = result.stderr.strip().splitlines()
1241
1300
  if stderr:
1242
- return stderr[-1]
1301
+ return _strip_userinfo(stderr[-1])
1243
1302
  stdout = result.stdout.strip().splitlines()
1244
1303
  if stdout:
1245
- return stdout[-1]
1304
+ return _strip_userinfo(stdout[-1])
1246
1305
  return f"exit {result.returncode}"
1247
1306
 
1248
1307
 
@@ -1250,9 +1309,10 @@ def _log_push_failure(remote: str, detail: str) -> None:
1250
1309
  """Log a ``notes-push-failed`` event naming the checkout when known.
1251
1310
 
1252
1311
  ``git_dir`` matches ``unhooked-repo`` so ``doctor`` can point at the
1253
- failing checkout the way it points at unhooked ones.
1312
+ failing checkout the way it points at unhooked ones. ``remote`` is
1313
+ logged without URL userinfo.
1254
1314
  """
1255
- fields: dict[str, str] = {"remote": remote, "detail": detail}
1315
+ fields: dict[str, str] = {"remote": _strip_userinfo(remote), "detail": detail}
1256
1316
  git_dir = _git_dir(os.getcwd())
1257
1317
  if git_dir is not None:
1258
1318
  fields["git_dir"] = str(git_dir)
@@ -1262,6 +1322,9 @@ def _log_push_failure(remote: str, detail: str) -> None:
1262
1322
  def cmd_push_notes(remote: str) -> int:
1263
1323
  """pre-push: reconcile then push the notes ref, best-effort.
1264
1324
 
1325
+ Syncs with ``origin`` only (``_is_notes_remote``); a push to any other
1326
+ remote logs ``notes-push-skipped`` and leaves both notes refs untouched.
1327
+
1265
1328
  A final failure prints one stderr line and logs a ``notes-push-failed``
1266
1329
  event to the local attribution log — a stderr line inside ``git push``
1267
1330
  output is not a signal anyone sees.
@@ -1275,12 +1338,17 @@ def cmd_push_notes(remote: str) -> int:
1275
1338
  return 0
1276
1339
  if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
1277
1340
  return 0 # nothing to push
1341
+ if not _is_notes_remote(remote):
1342
+ # A push to a fork is ordinary, so it is a skip and no failure.
1343
+ # Content-free: a pushed URL can carry credentials.
1344
+ _log_event("notes-push-skipped", git_dir=str(_git_dir(os.getcwd()) or ""))
1345
+ return 0
1278
1346
  result = _reconciled_push(remote)
1279
1347
  if result.returncode != 0:
1280
1348
  detail = _push_failure_detail(result)
1281
1349
  print(
1282
- f"sediment-attribution: notes push to {remote} failed "
1283
- f"(push continues): {detail}",
1350
+ f"sediment-attribution: notes push to {_strip_userinfo(remote)} "
1351
+ f"failed (push continues): {detail}",
1284
1352
  file=sys.stderr,
1285
1353
  )
1286
1354
  _log_push_failure(remote, detail)
@@ -1294,27 +1362,29 @@ def cmd_repair_notes(remote: str) -> int:
1294
1362
 
1295
1363
  The on-demand fix for a machine already in the diverged state, safe to
1296
1364
  run any time — fetch, union merge, push are each idempotent. On a fresh
1297
- machine with no local notes ref it adopts the remote's. Unlike the
1365
+ machine with no local notes ref it adopts the remote's. A ``remote``
1366
+ other than ``origin`` is refused (``notes_remote_not_origin``). Unlike the
1298
1367
  hooks this is NOT best-effort: it reports what happened and exits
1299
1368
  non-zero on failure so operators and scripts can trust the result.
1300
1369
  """
1301
1370
  if _git(["rev-parse", "--is-inside-work-tree"]) != "true":
1302
1371
  print("repair-notes: not inside a git work tree", file=sys.stderr)
1303
1372
  return 1
1373
+ shown = _strip_userinfo(remote)
1304
1374
  if not _reconcile_notes(remote):
1305
1375
  return 1
1306
1376
  if _git(["rev-parse", "--verify", "--quiet", NOTES_REF]) is None:
1307
- print(f"repair-notes: no notes ref locally or on {remote}; nothing to do")
1377
+ print(f"repair-notes: no notes ref locally or on {shown}; nothing to do")
1308
1378
  return 0
1309
1379
  result = _reconciled_push(remote)
1310
1380
  if result.returncode != 0:
1311
1381
  detail = _push_failure_detail(result)
1312
- print(f"repair-notes: push to {remote} failed: {detail}", file=sys.stderr)
1382
+ print(f"repair-notes: push to {shown} failed: {detail}", file=sys.stderr)
1313
1383
  _log_push_failure(remote, detail)
1314
1384
  return 1
1315
1385
  print(
1316
1386
  f"{ui.glyph('✓', 'phosphor')}repair-notes: notes ref reconciled "
1317
- f"and pushed to {remote}"
1387
+ f"and pushed to {shown}"
1318
1388
  )
1319
1389
  return 0
1320
1390
 
@@ -3758,7 +3828,17 @@ def build_parser() -> argparse.ArgumentParser:
3758
3828
  "--tool", required=True, choices=["claude-code", "codex", "cursor", "pi"]
3759
3829
  )
3760
3830
 
3761
- sub.add_parser("stamp", help="write the attribution note on HEAD (post-commit)")
3831
+ sub.add_parser(
3832
+ "stamp",
3833
+ help="write the attribution note on HEAD (post-commit)",
3834
+ description="Write this repository's pending Session markers to the "
3835
+ "note on HEAD under refs/notes/sediment, then clear the markers that "
3836
+ "it wrote. The post-commit hook runs this command after each commit. "
3837
+ "Run it yourself only to recover a pending stamp, while HEAD is still "
3838
+ "the commit that the markers belong to. The command always exits 0, "
3839
+ "prints each failure on stderr, and logs it to "
3840
+ "~/.sediment/attribution.log.",
3841
+ )
3762
3842
 
3763
3843
  p_squash = sub.add_parser(
3764
3844
  "union-squash-notes",
@@ -3775,9 +3855,20 @@ def build_parser() -> argparse.ArgumentParser:
3775
3855
 
3776
3856
  p_repair = sub.add_parser(
3777
3857
  "repair-notes",
3778
- help="reconcile the notes ref with a remote and push (operator fix)",
3858
+ help="reconcile a behind or diverged notes ref and push it (operator fix)",
3859
+ description="Fetch refs/notes/sediment from the remote, union-merge it "
3860
+ "into the local notes ref, and push the result. Run it inside a "
3861
+ "repository when `sediment doctor --fetch` reports a notes ref that "
3862
+ "is behind or diverged. Unlike the pre-push hook, the command exits 1 "
3863
+ "when it can't reconcile or push.",
3864
+ )
3865
+ p_repair.add_argument(
3866
+ "remote",
3867
+ nargs="?",
3868
+ default="origin",
3869
+ help="remote to fetch from and push to; use origin, the remote that "
3870
+ "doctor checks (default: origin)",
3779
3871
  )
3780
- p_repair.add_argument("remote", nargs="?", default="origin")
3781
3872
 
3782
3873
  p_doctor = sub.add_parser(
3783
3874
  "doctor",
@@ -3876,7 +3967,12 @@ def build_parser() -> argparse.ArgumentParser:
3876
3967
  )
3877
3968
 
3878
3969
  p_uninstall = sub.add_parser("uninstall", help="remove hooks from a repo")
3879
- p_uninstall.add_argument("repo", nargs="?", default=".")
3970
+ p_uninstall.add_argument(
3971
+ "repo",
3972
+ nargs="?",
3973
+ default=".",
3974
+ help="repository to remove the git hooks from (default: current directory)",
3975
+ )
3880
3976
  p_uninstall.add_argument(
3881
3977
  "--agents",
3882
3978
  action="store_true",
@@ -1596,9 +1596,10 @@ class _ArgumentParser(argparse.ArgumentParser):
1596
1596
  def _print_dispatched_help(parser: argparse.ArgumentParser, *, prog: str) -> int:
1597
1597
  """Render a forwarded command through the public CLI help contract.
1598
1598
 
1599
- Report, mirror-GC, and attribution modules keep standalone parsers because
1600
- their execution seams have different dependency constraints. The installed
1601
- command still owns their public path and presentation.
1599
+ Report, mirror-GC, attribution, and transcript modules keep standalone
1600
+ parsers because their execution seams have different dependency
1601
+ constraints. The installed command still owns their public path and
1602
+ presentation.
1602
1603
  """
1603
1604
  parser.prog = prog
1604
1605
  parser.formatter_class = _CoderFormatter
@@ -1815,8 +1816,8 @@ def build_parser() -> argparse.ArgumentParser:
1815
1816
  p_qc.add_argument("--reason", required=True)
1816
1817
  p_qc.set_defaults(func=cmd_quarantine_inference_calls)
1817
1818
 
1818
- # Stubs for --help only — the attribution dispatch in main() runs first
1819
- # (the report/mirror-gc pattern).
1819
+ # Stubs for --help only — the attribution and transcript dispatch in
1820
+ # main() runs first (the report/mirror-gc pattern).
1820
1821
  sub.add_parser(
1821
1822
  "install",
1822
1823
  help="wire a repo + this machine: git hooks, agent hooks, telemetry "
@@ -1826,6 +1827,15 @@ def build_parser() -> argparse.ArgumentParser:
1826
1827
  "uninstall", help="remove the per-repo git hooks (--agents: user-level too)"
1827
1828
  )
1828
1829
  sub.add_parser("doctor", help="check attribution + server health on this machine")
1830
+ # The docs tell people to run these three by hand; hooks also run `stamp`
1831
+ # and `transcript`.
1832
+ sub.add_parser(
1833
+ "repair-notes", help="reconcile a behind or diverged notes ref and push it"
1834
+ )
1835
+ sub.add_parser("stamp", help="write pending Session markers to the note on HEAD")
1836
+ sub.add_parser(
1837
+ "transcript", help="send one Session's Edit observations from its transcript"
1838
+ )
1829
1839
 
1830
1840
  p_server = sub.add_parser(
1831
1841
  "server", help="run a local API server with managed PostgreSQL"
@@ -2030,9 +2040,10 @@ def build_parser() -> argparse.ArgumentParser:
2030
2040
  # store and never construct Settings.
2031
2041
  _REMOTE_VERBS = {"login", "logout", "commit", "facts", "demo", "evidence"}
2032
2042
 
2033
- # Dispatched pre-argparse to the stdlib-only attribution module.
2034
- # install/uninstall/doctor get help stubs; the hook-plumbing verbs are
2035
- # execution-only (hidden from --help).
2043
+ # Dispatched pre-argparse to the stdlib-only attribution module. The verbs
2044
+ # that people run by hand (install, uninstall, doctor, stamp, repair-notes)
2045
+ # get help stubs; the hook-only verbs (cursor-hook, mark, union-squash-notes,
2046
+ # push-notes) are execution-only and stay out of --help.
2036
2047
  _ATTRIBUTION_VERBS = {
2037
2048
  "cursor-hook",
2038
2049
  "install",
@@ -2082,17 +2093,27 @@ def main(argv: list[str] | None = None) -> int:
2082
2093
  return _fail(str(exc))
2083
2094
  if argv[:1] == ["transcript"]:
2084
2095
  module = importlib.import_module("sediment_cli.transcript")
2096
+ # The hook entry point hand-parses argv and always exits 0, so help
2097
+ # returns here, before it reads capture configuration or stdin. No
2098
+ # hook passes a help flag.
2099
+ if {"-h", "--help"} & set(argv[1:]):
2100
+ return _print_dispatched_help(
2101
+ module.build_parser(), prog="sediment transcript"
2102
+ )
2085
2103
  return _public_status(module.main(argv[1:]))
2086
2104
  if argv[:1] and argv[0] in _ATTRIBUTION_VERBS:
2087
2105
  # Forwarded verbatim to the attribution module's own parser —
2088
- # stdlib-only, never constructs Settings. The plumbing verbs (mark,
2089
- # stamp, union-squash-notes, push-notes, repair-notes) get no help
2106
+ # stdlib-only, never constructs Settings. The hook-only verbs
2107
+ # (cursor-hook, mark, union-squash-notes, push-notes) get no help
2090
2108
  # stub: hooks invoke them, humans don't.
2091
2109
  module = importlib.import_module("sediment_cli.attribution")
2092
- if argv[0] in {"install", "uninstall", "doctor"} and argv[1:] in (
2093
- ["-h"],
2094
- ["--help"],
2095
- ):
2110
+ if argv[0] in {
2111
+ "install",
2112
+ "uninstall",
2113
+ "doctor",
2114
+ "stamp",
2115
+ "repair-notes",
2116
+ } and argv[1:] in (["-h"], ["--help"]):
2096
2117
  action = _subparsers_action(module.build_parser())
2097
2118
  return _print_dispatched_help(
2098
2119
  action.choices[argv[0]], prog=f"sediment {argv[0]}"
@@ -1687,6 +1687,43 @@ def _publish(url: str, payload: dict) -> bool:
1687
1687
  return complete
1688
1688
 
1689
1689
 
1690
+ def build_parser():
1691
+ """The command's shape, for ``sediment transcript --help`` and the CLI
1692
+ reference only."""
1693
+ # ponytail: a help-only twin of the hand parsing in main, which must exit
1694
+ # 0 where argparse exits 2. Two arguments described twice; drive both from
1695
+ # one table if the grammar grows.
1696
+ import argparse # the hook path never imports it
1697
+
1698
+ parser = argparse.ArgumentParser(
1699
+ prog="sediment transcript",
1700
+ # argparse prints options before positionals; main reads `snapshot`
1701
+ # only as the first argument.
1702
+ usage="%(prog)s [-h] [snapshot] --agent {" + ",".join(_PARSERS) + "}",
1703
+ description="Read one hook event on stdin: a JSON object with "
1704
+ "session_id and transcript_path. Extract the Session's Edit "
1705
+ "observations from that agent transcript, and send them to "
1706
+ "SEDIMENT_OTLP_ENDPOINT. For Claude Code, also send Rejected edits and "
1707
+ "Retry linkages. Agent session-end hooks run this command. Run it "
1708
+ "yourself to recover a Session that ended without its extractor. The "
1709
+ "command always exits 0 and reports problems on stderr. If "
1710
+ "SEDIMENT_OTLP_ENDPOINT is unset, it does nothing.",
1711
+ )
1712
+ parser.add_argument(
1713
+ "snapshot",
1714
+ nargs="?",
1715
+ help="run as the PreToolUse hook instead: record line hashes around "
1716
+ "one edit call in a local cache; must come before --agent",
1717
+ )
1718
+ parser.add_argument(
1719
+ "--agent",
1720
+ required=True,
1721
+ choices=tuple(_PARSERS),
1722
+ help="agent harness that wrote the transcript",
1723
+ )
1724
+ return parser
1725
+
1726
+
1690
1727
  def main(argv: list[str] | None = None) -> int:
1691
1728
  """The hook entry point — SessionEnd, or ``snapshot`` for PreToolUse.
1692
1729
 
@@ -24,13 +24,26 @@ import pytest
24
24
  from sediment_cli.cli import _REPORTS, build_parser, main
25
25
 
26
26
  GOLDEN_DIR = Path(__file__).parent / "testdata" / "help"
27
- _DISPATCH_ONLY = {"report", "mirror-gc", "install", "uninstall", "doctor", "delivery"}
27
+ _DISPATCH_ONLY = {
28
+ "report",
29
+ "mirror-gc",
30
+ "install",
31
+ "uninstall",
32
+ "doctor",
33
+ "stamp",
34
+ "repair-notes",
35
+ "transcript",
36
+ "delivery",
37
+ }
28
38
  _DISPATCH_PATHS = [
29
39
  ("report",),
30
40
  ("mirror-gc",),
31
41
  ("install",),
32
42
  ("uninstall",),
33
43
  ("doctor",),
44
+ ("stamp",),
45
+ ("repair-notes",),
46
+ ("transcript",),
34
47
  ("delivery",),
35
48
  *(("delivery", name) for name in ("enqueue", "status", "replay")),
36
49
  *(("report", name) for name in _REPORTS),
@@ -137,6 +150,42 @@ def test_dispatched_runtime_help_uses_public_command_path(path) -> None:
137
150
  assert f"sediment {' '.join(path)}" in captured.stdout.splitlines()[1]
138
151
 
139
152
 
153
+ def test_hook_only_verbs_stay_out_of_help() -> None:
154
+ # Hooks invoke these; listing them invites hand-running them. The docs
155
+ # tell people to run the other three, so those are listed.
156
+ commands = set(_subparsers_action(build_parser()).choices)
157
+
158
+ assert not commands & {"mark", "cursor-hook", "union-squash-notes", "push-notes"}
159
+ assert {"stamp", "repair-notes", "transcript"} <= commands
160
+
161
+
162
+ @pytest.mark.parametrize(
163
+ "argv", (["--help"], ["-h"], ["snapshot", "--agent", "pi", "--help"])
164
+ )
165
+ def test_transcript_help_returns_before_the_hook_reads_anything(
166
+ argv, monkeypatch, capsys
167
+ ) -> None:
168
+ from sediment_cli import transcript
169
+
170
+ touched: list[str] = []
171
+
172
+ class _Stdin:
173
+ def read(self):
174
+ touched.append("stdin")
175
+ return "{}"
176
+
177
+ # A configured endpoint, so a regression into the hook path would go on
178
+ # to read stdin; the hook's own fail-soft would hide a raised error.
179
+ monkeypatch.setattr(
180
+ transcript, "_endpoint", lambda: touched.append("config") or "http://127.0.0.1"
181
+ )
182
+ monkeypatch.setattr(sys, "stdin", _Stdin())
183
+
184
+ assert main(["transcript", *argv]) == 0
185
+ assert touched == []
186
+ assert capsys.readouterr().out.startswith("USAGE:\n sediment transcript ")
187
+
188
+
140
189
  def test_report_registry_uses_attribution_vocabulary() -> None:
141
190
  captured = _installed_help(("report",))
142
191
 
@@ -0,0 +1,14 @@
1
+ USAGE:
2
+ sediment repair-notes [-h] [remote]
3
+
4
+ Fetch refs/notes/sediment from the remote, union-merge it into the local
5
+ notes ref, and push the result. Run it inside a repository when `sediment
6
+ doctor --fetch` reports a notes ref that is behind or diverged. Unlike the
7
+ pre-push hook, the command exits 1 when it can't reconcile or push.
8
+
9
+ ARGUMENTS:
10
+ remote remote to fetch from and push to; use origin, the remote that
11
+ doctor checks (default: origin)
12
+
13
+ OPTIONS:
14
+ -h, --help show this help message and exit
@@ -1,4 +1,4 @@
1
- sediment v0.6.0 — A self-hosted evidence store for coding agents.
1
+ sediment v0.6.1 — A self-hosted evidence store for coding agents.
2
2
 
3
3
  USAGE:
4
4
  sediment <command> [options]
@@ -27,6 +27,9 @@ COMMANDS:
27
27
  telemetry env (sediment install --help)
28
28
  uninstall remove the per-repo git hooks (--agents: user-level too)
29
29
  doctor check attribution + server health on this machine
30
+ repair-notes reconcile a behind or diverged notes ref and push it
31
+ stamp write pending Session markers to the note on HEAD
32
+ transcript send one Session's Edit observations from its transcript
30
33
  server run a local API server with managed PostgreSQL
31
34
  login store credentials for a server
32
35
  logout remove stored credentials
@@ -0,0 +1,12 @@
1
+ USAGE:
2
+ sediment stamp [-h]
3
+
4
+ Write this repository's pending Session markers to the note on HEAD under
5
+ refs/notes/sediment, then clear the markers that it wrote. The post-commit
6
+ hook runs this command after each commit. Run it yourself only to recover a
7
+ pending stamp, while HEAD is still the commit that the markers belong to.
8
+ The command always exits 0, prints each failure on stderr, and logs it to
9
+ ~/.sediment/attribution.log.
10
+
11
+ OPTIONS:
12
+ -h, --help show this help message and exit
@@ -0,0 +1,20 @@
1
+ USAGE:
2
+ sediment transcript [-h] [snapshot] --agent {claude-code,codex,pi}
3
+
4
+ Read one hook event on stdin: a JSON object with session_id and
5
+ transcript_path. Extract the Session's Edit observations from that agent
6
+ transcript, and send them to SEDIMENT_OTLP_ENDPOINT. For Claude Code, also
7
+ send Rejected edits and Retry linkages. Agent session-end hooks run this
8
+ command. Run it yourself to recover a Session that ended without its
9
+ extractor. The command always exits 0 and reports problems on stderr. If
10
+ SEDIMENT_OTLP_ENDPOINT is unset, it does nothing.
11
+
12
+ ARGUMENTS:
13
+ snapshot run as the PreToolUse hook instead: record line hashes
14
+ around one edit call in a local cache; must come before
15
+ --agent
16
+
17
+ OPTIONS:
18
+ -h, --help show this help message and exit
19
+ --agent {claude-code,codex,pi}
20
+ agent harness that wrote the transcript
@@ -2,7 +2,8 @@ USAGE:
2
2
  sediment uninstall [-h] [--agents] [repo]
3
3
 
4
4
  ARGUMENTS:
5
- repo
5
+ repo repository to remove the git hooks from (default: current
6
+ directory)
6
7
 
7
8
  OPTIONS:
8
9
  -h, --help show this help message and exit
File without changes
File without changes
File without changes