awgit 0.2.0__tar.gz → 0.3.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 (39) hide show
  1. {awgit-0.2.0 → awgit-0.3.1}/PKG-INFO +3 -1
  2. {awgit-0.2.0 → awgit-0.3.1}/awgit/capture.py +68 -2
  3. {awgit-0.2.0 → awgit-0.3.1}/awgit/cli.py +272 -5
  4. awgit-0.3.1/awgit/evidence.py +137 -0
  5. awgit-0.3.1/awgit/graph.py +133 -0
  6. {awgit-0.2.0 → awgit-0.3.1}/awgit/leases.py +47 -0
  7. awgit-0.3.1/awgit/repowise_parser.py +121 -0
  8. awgit-0.3.1/awgit/staging.py +262 -0
  9. awgit-0.3.1/awgit/staging_selftest.py +177 -0
  10. {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/PKG-INFO +3 -1
  11. {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/SOURCES.txt +7 -1
  12. awgit-0.3.1/awgit.egg-info/requires.txt +4 -0
  13. {awgit-0.2.0 → awgit-0.3.1}/pyproject.toml +9 -1
  14. {awgit-0.2.0 → awgit-0.3.1}/tests/test_awgit_standalone.py +72 -0
  15. awgit-0.3.1/tests/test_multilang_identity.py +80 -0
  16. awgit-0.2.0/awgit.egg-info/requires.txt +0 -1
  17. {awgit-0.2.0 → awgit-0.3.1}/LICENSE +0 -0
  18. {awgit-0.2.0 → awgit-0.3.1}/README.md +0 -0
  19. {awgit-0.2.0 → awgit-0.3.1}/awgit/__init__.py +0 -0
  20. {awgit-0.2.0 → awgit-0.3.1}/awgit/bodies.py +0 -0
  21. {awgit-0.2.0 → awgit-0.3.1}/awgit/bridge.py +0 -0
  22. {awgit-0.2.0 → awgit-0.3.1}/awgit/data_root.py +0 -0
  23. {awgit-0.2.0 → awgit-0.3.1}/awgit/diff.py +0 -0
  24. {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/chain.sh +0 -0
  25. {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  26. {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  27. {awgit-0.2.0 → awgit-0.3.1}/awgit/identity.py +0 -0
  28. {awgit-0.2.0 → awgit-0.3.1}/awgit/ledger.py +0 -0
  29. {awgit-0.2.0 → awgit-0.3.1}/awgit/mcp.py +0 -0
  30. {awgit-0.2.0 → awgit-0.3.1}/awgit/merge.py +0 -0
  31. {awgit-0.2.0 → awgit-0.3.1}/awgit/nodeid.py +0 -0
  32. {awgit-0.2.0 → awgit-0.3.1}/awgit/oplog.py +0 -0
  33. {awgit-0.2.0 → awgit-0.3.1}/awgit/parser.py +0 -0
  34. {awgit-0.2.0 → awgit-0.3.1}/awgit/schema.py +0 -0
  35. {awgit-0.2.0 → awgit-0.3.1}/awgit/sync.py +0 -0
  36. {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/dependency_links.txt +0 -0
  37. {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/entry_points.txt +0 -0
  38. {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/top_level.txt +0 -0
  39. {awgit-0.2.0 → awgit-0.3.1}/setup.cfg +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awgit
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Aither World-Graph git — semantic version control on top of git: edit-ops keyed on stable node ids, content-addressed bodies, differential sync.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: httpx>=0.25.0
10
+ Provides-Extra: multilang
11
+ Requires-Dist: repowise>=0.31.0; extra == "multilang"
10
12
  Dynamic: license-file
11
13
 
12
14
  # awgit — Aither World-Graph git
@@ -128,10 +128,23 @@ def resolve_actor(
128
128
  """
129
129
  github = _github_identity(data_root)
130
130
  env_actor = os.environ.get("AITHER_ACTOR")
131
+ # The SESSION, before the GitHub login. Every agent on this box shares one
132
+ # GitHub identity, so resolving to `github` collapsed every session into one
133
+ # actor: measured 2026-08-09, an op-log of 188 ops across 189 files had
134
+ # exactly ONE actor, which makes "two actors touched this node" impossible to
135
+ # express and the collision view structurally blind. The schema already
136
+ # separates CLAIMED from VERIFIED — `verified_actor` still records the
137
+ # GitHub identity below — so the claimed actor is free to be the session
138
+ # that actually made the edit, which is what attribution is for. Matches
139
+ # the lease gate's `_actor()`, and the two disagreeing is what made leases
140
+ # discriminate per session while captures did not.
141
+ session = os.environ.get("CLAUDE_CODE_SESSION_ID")
131
142
  if actor_arg:
132
143
  actor, source = actor_arg, "arg"
133
144
  elif env_actor:
134
145
  actor, source = env_actor, "env"
146
+ elif session:
147
+ actor, source = f"claude:{session}", "session"
135
148
  elif github:
136
149
  actor, source = github, "github"
137
150
  else:
@@ -151,9 +164,57 @@ def _ancestor_shas(repo: Path, sha: str) -> List[str]:
151
164
  return out[1:] # first entry is sha itself
152
165
 
153
166
 
167
+ def _was_leased(actor: str, files: List[str]) -> bool:
168
+ """Did `actor` hold leases covering every guarded file in this commit?
169
+
170
+ This was HARDCODED False, so the op-log's record of whether work was leased
171
+ was a constant — measured 2026-08-09, 40 consecutive ops said `leased=false`
172
+ including commits made while the author demonstrably held leases. That makes
173
+ lease ADOPTION unmeasurable: the one field that could answer "are agents
174
+ actually using this?" always answered no. Same silent-no-op class as a
175
+ checker that cannot fail.
176
+
177
+ Evaluated at capture time, i.e. just after the commit, while the lease is
178
+ typically still live. A lease that expired between commit and capture reads
179
+ as unleased — deliberately conservative: this records evidence, and absent
180
+ evidence must not read as proof.
181
+ """
182
+ try:
183
+ from awgit.leases import coverage_gap # noqa: PLC0415
184
+
185
+ guarded_missing = coverage_gap(list(files or []), actor)
186
+ return not guarded_missing
187
+ except Exception:
188
+ # Never let attribution bookkeeping break a capture.
189
+ return False
190
+
191
+
154
192
  def _node_records(src: Optional[bytes], rel_path: str) -> List[Dict[str, Any]]:
155
193
  if src is None:
156
194
  return []
195
+ if not rel_path.endswith(".py"):
196
+ # NON-PYTHON: identity comes from repowise, which parses 75 extensions
197
+ # across 20+ languages and emits ids of the form `path::qualified_name`
198
+ # — verified stable across a symbol MOVING and its body changing, which
199
+ # is the property node-level merge rests on. Without this every
200
+ # .ts/.tsx/.go/.cs file was invisible to awgit: of the 20,451 files
201
+ # repowise has indexed in this repo, 12,600+ are not Python.
202
+ from awgit.repowise_parser import parse_symbols
203
+
204
+ text = src.decode("utf-8", errors="ignore").split("\n")
205
+ out: List[Dict[str, Any]] = []
206
+ for sym in parse_symbols(src, rel_path):
207
+ start, end = sym.get("start_line") or 0, sym.get("end_line") or 0
208
+ body = "\n".join(text[start - 1:end]) if start and end else ""
209
+ out.append({
210
+ "name": sym["symbol"] or sym["node_id"],
211
+ "path": rel_path,
212
+ "type": sym.get("kind") or "symbol",
213
+ "signature": "",
214
+ "body": body,
215
+ })
216
+ return out
217
+
157
218
  # lazy: CodeGraph's import chain is ~15s (AitherConfig auto-tune etc.); only
158
219
  # pay it when actually parsing (capture/diff/merge runtime), never for
159
220
  # `vcs lease`/`status` which import this module but never parse.
@@ -401,8 +462,13 @@ def capture_ops(
401
462
  manager = StableNodeIDManager(path=data / "nodes.json", persist=True)
402
463
  store = BodyStore(data_root=data)
403
464
  node_changes: List[NodeChange] = []
465
+ from awgit.repowise_parser import language_for
466
+
404
467
  for rel in files:
405
- if not rel.endswith(".py"):
468
+ # Python natively; anything else only when repowise can parse it,
469
+ # so a file type nobody can give node identity to is skipped rather
470
+ # than recorded as an empty change.
471
+ if not rel.endswith(".py") and not language_for(rel):
406
472
  continue
407
473
  old_src = git_blob(repo, parent, rel)
408
474
  new_src = git_blob(repo, git_sha, rel)
@@ -426,7 +492,7 @@ def capture_ops(
426
492
  file_paths=files,
427
493
  node_changes=node_changes,
428
494
  summary=_make_summary(actor_name, node_changes),
429
- leased=False,
495
+ leased=_was_leased(actor_name, files),
430
496
  actor_verified=bool(prov["actor_verified"]),
431
497
  actor_source=str(prov["actor_source"]),
432
498
  verified_actor=str(prov["verified_actor"]),
@@ -156,8 +156,39 @@ def _cmd_lease(args: argparse.Namespace) -> int:
156
156
  from awgit.leases import is_guarded
157
157
 
158
158
  repo = Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
159
- targets += [f for f in _staged_files(repo) if is_guarded(f)]
160
- targets = sorted(set(targets))
159
+ named = set(targets)
160
+ staged = [f for f in _staged_files(repo) if is_guarded(f)]
161
+ # ADOPTION: files that are staged but that this caller never named.
162
+ # In a shared worktree they are routinely somebody else's — a peer
163
+ # stages while you are mid-command — and leasing them is what makes
164
+ # the pre-commit gate print OK on a sweep, because you then genuinely
165
+ # hold a lease on their work. Measured 2026-08-10 (D-1887): three
166
+ # portal-kit files a peer had staged seconds earlier were adopted in
167
+ # silence, and 9 of their files landed in someone else's commit. The
168
+ # gate CANNOT catch this at commit time — the committer's leases are
169
+ # all valid — so it is caught here, at the moment of adoption.
170
+ adopted = sorted(f for f in staged if f not in named)
171
+ if adopted and not getattr(args, "adopt", False):
172
+ print("vcs: REFUSED — these files are staged but you did not name "
173
+ "them:", file=sys.stderr)
174
+ for adopted_path in adopted:
175
+ print("vcs: " + adopted_path, file=sys.stderr)
176
+ print("vcs: in a shared worktree these are routinely a PEER's "
177
+ "in-flight work, and leasing them makes the pre-commit gate "
178
+ "pass on a commit that sweeps it (D-1887).", file=sys.stderr)
179
+ print("vcs: name your own paths instead, or re-run with --adopt if "
180
+ "you have read that list and every file is yours.",
181
+ file=sys.stderr)
182
+ return 1
183
+ if adopted:
184
+ # Proceeding deliberately still gets its own block: the failure
185
+ # mode was these paths being indistinguishable from the ones the
186
+ # caller actually asked for.
187
+ print("vcs: ADOPTING " + str(len(adopted))
188
+ + " staged file(s) you did not name:")
189
+ for adopted_path in adopted:
190
+ print("vcs: + " + adopted_path)
191
+ targets = sorted(named | set(staged))
161
192
  if not targets:
162
193
  print("vcs: nothing staged that the gate guards — no leases needed")
163
194
  return 0
@@ -171,8 +202,29 @@ def _cmd_lease(args: argparse.Namespace) -> int:
171
202
  except LeaseConflictError as exc:
172
203
  print(f"vcs: {exc}", file=sys.stderr)
173
204
  return 1
205
+ # A lease over an ALREADY-DIRTY file captures a baseline that contains work
206
+ # which is not yours, and `stage-mine` computes (baseline -> worktree), so it
207
+ # cannot separate what it never saw as separate. Leasing after a peer has
208
+ # started editing therefore looks exactly like leasing a clean file, and the
209
+ # commit sweeps them — measured 2026-08-10, ~29 lines of a peer's in-flight
210
+ # HYG004 work landed in someone else's commit that way.
211
+ #
212
+ # This cannot REFUSE: the dirt is often your own (edit, then remember to
213
+ # lease), and refusing would break the common case. So it says so, loudly,
214
+ # once per dirty target, and names the fix.
215
+ dirty = _dirty_targets([lz.target for lz in leases])
174
216
  for lz in leases:
175
217
  print(f"vcs: lease {lz.lease_id} {lz.target} until {lz.expires_ts}")
218
+ if dirty:
219
+ print("vcs: WARNING — leased with UNCOMMITTED changes already present:",
220
+ file=sys.stderr)
221
+ for rel in dirty:
222
+ print(f"vcs: ! {rel}", file=sys.stderr)
223
+ print("vcs: the baseline just snapshotted INCLUDES those changes, so "
224
+ "`awgit stage-mine` cannot tell them from yours.", file=sys.stderr)
225
+ print("vcs: if any of it is a peer's, verify before committing: "
226
+ "`git diff --stat -- <path>` must match the size of YOUR edit.",
227
+ file=sys.stderr)
176
228
  return 0
177
229
  if cmd == "heartbeat":
178
230
  print(f"vcs: heartbeat refreshed {registry.heartbeat(who, args.ids)} leases")
@@ -203,6 +255,66 @@ def _staged_files(repo: Path) -> List[str]:
203
255
  return [ln for ln in out.splitlines() if ln.strip()]
204
256
 
205
257
 
258
+ def _cmd_stage_mine(args: argparse.Namespace) -> int:
259
+ """Stage only this actor's edits, and refuse if any of them would be lost."""
260
+ from awgit.staging import StagingError, stage_mine, verify_staged
261
+
262
+ if getattr(args, "self_test", False):
263
+ from awgit.staging_selftest import run_self_test
264
+ return run_self_test()
265
+
266
+ repo = Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
267
+ who = _actor(args)
268
+ registry = LeaseRegistry()
269
+ held = {lz.target: lz for lz in registry.leases_by_actor(who) if lz.status == "active"}
270
+
271
+ failed = False
272
+ for rel in args.paths:
273
+ rel = rel.replace("\\", "/")
274
+ lease = held.get(rel)
275
+ if lease is None:
276
+ # Without a lease there is no baseline, and without a baseline "your
277
+ # edits" is a guess. Refusing is the whole point of the command.
278
+ print(
279
+ f"vcs: {rel}: no active lease for {who!r} — take it BEFORE editing "
280
+ f"(awgit lease acquire {rel})",
281
+ file=sys.stderr,
282
+ )
283
+ failed = True
284
+ continue
285
+ try:
286
+ result = stage_mine(rel, lease.baseline_blob, repo, dry_run=args.dry_run)
287
+ except StagingError as exc:
288
+ print(f"vcs: {exc}", file=sys.stderr)
289
+ failed = True
290
+ continue
291
+
292
+ if result.missing:
293
+ print(f"vcs: {rel}: {result.note}", file=sys.stderr)
294
+ for line in result.missing[:8]:
295
+ print(f" lost: {line[:100]}", file=sys.stderr)
296
+ failed = True
297
+ continue
298
+
299
+ print(f"vcs: {rel}: {result.note}")
300
+
301
+ if result.staged and args.require:
302
+ absent = verify_staged(rel, args.require, repo)
303
+ if absent:
304
+ # This is the assertion that would have caught the 2026-08-10
305
+ # dropped-registration bug: the function was staged, the line
306
+ # wiring it was not, and everything else looked correct.
307
+ print(
308
+ f"vcs: {rel}: REQUIRED text missing from the STAGED copy — "
309
+ f"your change is staged incomplete:",
310
+ file=sys.stderr,
311
+ )
312
+ for needle in absent:
313
+ print(f" missing: {needle[:100]}", file=sys.stderr)
314
+ failed = True
315
+ return 1 if failed else 0
316
+
317
+
206
318
  def _cmd_lease_check(args: argparse.Namespace) -> int:
207
319
  if os.environ.get("VCS_LEASES_ENFORCE", "0") != "1":
208
320
  print("vcs: lease-check not enforced (VCS_LEASES_ENFORCE=0)")
@@ -223,6 +335,34 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
223
335
  return 0
224
336
 
225
337
 
338
+
339
+ def _cmd_graph(args: argparse.Namespace) -> int:
340
+ from awgit.graph import build, to_json, to_mermaid
341
+
342
+ g = build(since=args.since, actor=args.actor)
343
+ text = to_json(g) if args.format == "json" else to_mermaid(g)
344
+ if args.out:
345
+ try:
346
+ Path(args.out).write_text(text, encoding="utf-8")
347
+ except OSError as exc:
348
+ print(f"vcs: cannot write {args.out}: {exc}", file=sys.stderr)
349
+ return 2
350
+ print(f"vcs: wrote {args.format} graph to {args.out} "
351
+ f"({g['ops']} ops, {len(g['collisions'])} collision(s))")
352
+ else:
353
+ print(text)
354
+ return 0
355
+
356
+
357
+
358
+ def _cmd_evidence(args: argparse.Namespace) -> int:
359
+ from awgit.evidence import gather, render, to_json
360
+
361
+ ev = gather(since=args.since)
362
+ print(to_json(ev) if args.json else render(ev))
363
+ return 0
364
+
365
+
226
366
  def _cmd_bodies(args: argparse.Namespace) -> int:
227
367
  from awgit.bodies import BodyStore
228
368
 
@@ -300,24 +440,72 @@ def _cmd_dedupe(args: argparse.Namespace) -> int:
300
440
 
301
441
  def _cmd_ledger(args: argparse.Namespace) -> int:
302
442
  """Attribution view — who changed what, under a verified GitHub identity."""
443
+ import json as _json
444
+
303
445
  from awgit.ledger import op_to_ledger_entry
304
446
  from awgit.oplog import OpLog
305
447
 
306
448
  ops = OpLog().all_ops()
307
449
  if args.op:
308
- ops = [o for o in ops if o.op_id == args.op]
450
+ # 🪤 `--op` used to match ONLY `op_id`, while the listing below prints
451
+ # `ledger_ref` as its first column and the op_id NOWHERE. So the one
452
+ # identifier the command hands you was the one identifier it refused,
453
+ # and `awgit ledger --op <id-copied-from-awgit-ledger>` answered
454
+ # "no ops match" — which reads as "that op does not exist" rather than
455
+ # "you passed the wrong one of two ids you were never shown". Accept
456
+ # either; they are both stable handles for the same op.
457
+ # 🪤 PREFIX match, not equality. The listing abbreviates op_id to 16
458
+ # chars, so an exact-match lookup rejects the very string it printed —
459
+ # the identical defect one layer down, and it was reintroduced while
460
+ # fixing the first one. Git accepts short shas for exactly this reason.
461
+ wanted = args.op
462
+ matches = [
463
+ o for o in ops
464
+ if o.op_id.startswith(wanted)
465
+ or (op_to_ledger_entry(o).ledger_ref or "").startswith(wanted)
466
+ ]
467
+ if len(matches) > 1 and not any(
468
+ o.op_id == wanted or op_to_ledger_entry(o).ledger_ref == wanted
469
+ for o in matches
470
+ ):
471
+ # Ambiguity must be LOUD. Silently taking the first match is how a
472
+ # lookup starts answering about the wrong op.
473
+ print(
474
+ f"vcs: ledger: '{wanted}' is ambiguous ({len(matches)} ops match) "
475
+ "— use more characters",
476
+ file=sys.stderr,
477
+ )
478
+ return 1
479
+ ops = matches
309
480
  elif args.sha:
310
481
  ops = [o for o in ops if o.git_sha == args.sha]
311
482
  if not ops:
312
483
  print("vcs: ledger: no ops match", file=sys.stderr)
313
484
  return 1
485
+
486
+ if getattr(args, "json", False):
487
+ # Machine surface. The text form is lossy on purpose (it is a human
488
+ # attribution view), so anything programmatic — a world-model seeder, a
489
+ # reward program, an export — needs the full op rather than a re-parse
490
+ # of a display string that was never a contract.
491
+ entries = []
492
+ for op in ops:
493
+ entry = op_to_ledger_entry(op)
494
+ d = op.to_dict()
495
+ d["ledger_ref"] = entry.ledger_ref
496
+ entries.append(d)
497
+ print(_json.dumps(entries, indent=2, sort_keys=True))
498
+ return 0
499
+
314
500
  for op in ops:
315
501
  entry = op_to_ledger_entry(op)
316
502
  verified = (
317
503
  f" (verified {entry.verified_actor})" if entry.actor_verified else ""
318
504
  )
505
+ # op_id is printed too: it is half of what `--op` accepts, and omitting
506
+ # it is what made the lookup unusable from this command's own output.
319
507
  print(
320
- f"{entry.ledger_ref} {entry.actor}{verified} "
508
+ f"{entry.ledger_ref} {op.op_id[:16]} {entry.actor}{verified} "
321
509
  f"{entry.git_sha[:10]} {entry.node_changes} node_changes {entry.ts}"
322
510
  )
323
511
  return 0
@@ -384,6 +572,39 @@ def _cmd_hooks(args: argparse.Namespace) -> int:
384
572
  return 2
385
573
 
386
574
 
575
+
576
+ def _dirty_targets(targets):
577
+ """Which of these paths already carry uncommitted changes.
578
+
579
+ Best-effort and NEVER fatal: this runs on the happy path of `lease acquire`, and a
580
+ git hiccup must not stop someone taking a lease. Returning [] on failure is safe
581
+ because the warning is advisory — the lease itself is unaffected.
582
+ """
583
+ if not targets:
584
+ return []
585
+ try:
586
+ import subprocess
587
+
588
+ proc = subprocess.run(
589
+ ["git", "status", "--porcelain", "--", *targets],
590
+ capture_output=True,
591
+ text=True,
592
+ encoding="utf-8",
593
+ errors="replace",
594
+ timeout=10,
595
+ )
596
+ except Exception:
597
+ return []
598
+ if proc.returncode != 0:
599
+ return []
600
+ out = []
601
+ for line in proc.stdout.splitlines():
602
+ rel = line[3:].strip().strip('"')
603
+ if rel:
604
+ out.append(rel)
605
+ return out
606
+
607
+
387
608
  def main(argv: Optional[List[str]] = None) -> int:
388
609
  parser = argparse.ArgumentParser(
389
610
  prog="awgit",
@@ -405,6 +626,19 @@ def main(argv: Optional[List[str]] = None) -> int:
405
626
  p_diff.add_argument("b", help="target sha")
406
627
 
407
628
  sub.add_parser("status", help="op-log status")
629
+ p_graph = sub.add_parser(
630
+ "graph", help="render the op-log as a graph (mermaid or json)")
631
+ p_ev = sub.add_parser(
632
+ "evidence", help="the measurable claim, computed from your own op-log")
633
+ p_ev.add_argument("--json", action="store_true")
634
+ p_ev.add_argument("--since", default=None, help="ISO timestamp")
635
+ p_graph.add_argument("--format", choices=("mermaid", "json"),
636
+ default="mermaid")
637
+ p_graph.add_argument("--since", default=None,
638
+ help="ISO timestamp — only ops at or after it")
639
+ p_graph.add_argument("--actor", default=None, help="restrict to one actor")
640
+ p_graph.add_argument("--out", default=None,
641
+ help="write to a file instead of stdout")
408
642
 
409
643
  p_mp = sub.add_parser("merge-preview", help="node-level merge preview of two shas")
410
644
  p_mp.add_argument("a", help="base-side sha")
@@ -425,6 +659,10 @@ def main(argv: Optional[List[str]] = None) -> int:
425
659
  p_la.add_argument("--staged", action="store_true",
426
660
  help="also lease every STAGED file the gate guards "
427
661
  "(the one-command way to satisfy the pre-commit gate)")
662
+ p_la.add_argument("--adopt", action="store_true",
663
+ help="with --staged: proceed even though some staged files "
664
+ "were not named. READ THE LIST FIRST — in a shared "
665
+ "worktree they are routinely a peer's work (D-1887)")
428
666
  p_la.add_argument("--ttl", type=int, default=300)
429
667
  p_la.add_argument("--reason", default="")
430
668
  p_la.add_argument("--actor", default=None)
@@ -440,6 +678,23 @@ def main(argv: Optional[List[str]] = None) -> int:
440
678
  p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
441
679
  p_lc.add_argument("--actor", default=None)
442
680
 
681
+ p_sm = sub.add_parser(
682
+ "stage-mine",
683
+ help="stage ONLY your edits to files other sessions are also editing",
684
+ )
685
+ # nargs="*" so `--self-test` needs no dummy path — a gate you cannot run
686
+ # without inventing an argument is a gate that does not get run.
687
+ p_sm.add_argument("paths", nargs="*", help="repo-relative paths you hold a lease on")
688
+ p_sm.add_argument("--actor", default=None)
689
+ p_sm.add_argument("--dry-run", action="store_true",
690
+ help="report what would be staged without touching the index")
691
+ p_sm.add_argument("--require", action="append", default=[], metavar="TEXT",
692
+ help="text that MUST appear in the staged copy; repeatable. "
693
+ "Use it for the line that wires your change up — that is "
694
+ "the one a heuristic drops.")
695
+ p_sm.add_argument("--self-test", action="store_true",
696
+ help="prove the merge and the completeness assertion still work")
697
+
443
698
  p_bodies = sub.add_parser(
444
699
  "bodies", help="content-addressed body store (read a sha / stats)"
445
700
  )
@@ -468,8 +723,14 @@ def main(argv: Optional[List[str]] = None) -> int:
468
723
  p_ledger = sub.add_parser(
469
724
  "ledger", help="op-log as attribution records (who changed what)"
470
725
  )
471
- p_ledger.add_argument("--op", default=None, help="op_id to show")
726
+ p_ledger.add_argument(
727
+ "--op", default=None, help="op_id OR ledger_ref to show (either is accepted)"
728
+ )
472
729
  p_ledger.add_argument("--sha", default=None, help="git sha to show")
730
+ p_ledger.add_argument(
731
+ "--json", action="store_true",
732
+ help="emit full ops as JSON (machine surface; the text form is lossy)",
733
+ )
473
734
 
474
735
  p_sync = sub.add_parser(
475
736
  "sync", help="differential sync over the mesh (ops + bodies)"
@@ -518,6 +779,12 @@ def main(argv: Optional[List[str]] = None) -> int:
518
779
  return _cmd_lease(args)
519
780
  if args.cmd == "lease-check":
520
781
  return _cmd_lease_check(args)
782
+ if args.cmd == "stage-mine":
783
+ return _cmd_stage_mine(args)
784
+ if args.cmd == "graph":
785
+ return _cmd_graph(args)
786
+ if args.cmd == "evidence":
787
+ return _cmd_evidence(args)
521
788
  if args.cmd == "bodies":
522
789
  return _cmd_bodies(args)
523
790
  if args.cmd == "dedupe":
@@ -0,0 +1,137 @@
1
+ """The evidence for awgit's own claim, computed from YOUR op-log.
2
+
3
+ awgit asserts something measurable: that agents working in one repo collide on
4
+ the same code, that git cannot see it, and that node identity plus leases can.
5
+ For a long time that was an argument. This turns it into a number anyone can
6
+ run against their own history:
7
+
8
+ awgit evidence # human summary
9
+ awgit evidence --json # machine-readable, for a dashboard or a badge
10
+
11
+ What it reports, and why each one:
12
+
13
+ ops / actors Is anything being captured, and by more than one identity?
14
+ A single-actor op-log CANNOT express a collision, so this
15
+ is the precondition for every other number being real.
16
+ lease adoption What share of captured work was leased. This was
17
+ unmeasurable until `leased` stopped being hardcoded.
18
+ collisions Nodes two or more actors have touched — the thing git
19
+ shows you nothing about.
20
+ languages Node identity is only as broad as the parser; a Python-only
21
+ install honestly reports Python-only coverage.
22
+
23
+ LOCAL AND OFFLINE, ALWAYS. This reads the op-log on disk and talks to nothing.
24
+ awgit is a version-control tool: it sees every line of proprietary code its user
25
+ writes, and a tool in that position that phones home — even "anonymously" — has
26
+ made a decision for its user that is not its to make. If aggregate sharing ever
27
+ happens it must be an explicit, separate, opt-IN action with the payload visible
28
+ first. `--json` exists so a user can look at exactly what they would share.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from collections import Counter, defaultdict
34
+ from typing import Optional
35
+
36
+ from awgit.oplog import OpLog
37
+
38
+
39
+ def _language_of(path: str) -> str:
40
+ _, _, ext = (path or "").rpartition(".")
41
+ return ext.lower() if ext else "(none)"
42
+
43
+
44
+ def gather(data_root=None, since: Optional[str] = None) -> dict:
45
+ """Compute the evidence. Pure read of the op-log; no network, no writes."""
46
+ log = OpLog(data_root=data_root)
47
+ ops = log.ops_since(since) if since else log.all_ops()
48
+
49
+ actors: Counter = Counter()
50
+ leased = 0
51
+ langs: Counter = Counter()
52
+ touched: dict = defaultdict(set) # (path, node_id) -> {actor}
53
+ nodes = 0
54
+
55
+ for op in ops:
56
+ who = getattr(op, "actor", None) or "unknown"
57
+ actors[who] += 1
58
+ if getattr(op, "leased", False):
59
+ leased += 1
60
+ for ch in (getattr(op, "node_changes", None) or []):
61
+ nid = getattr(ch, "node_id", None)
62
+ if not nid:
63
+ continue
64
+ nodes += 1
65
+ path = getattr(ch, "path", None) or "?"
66
+ langs[_language_of(path)] += 1
67
+ touched[(path, nid)].add(who)
68
+
69
+ collisions = [
70
+ {"path": p, "node_id": n, "actors": sorted(a)}
71
+ for (p, n), a in touched.items() if len(a) > 1
72
+ ]
73
+ # Split the headline number, because the raw count OVER-STATES the claim.
74
+ # An op-log that spans an attribution change contains collisions where the
75
+ # same worker appears under an old label and a new one — real-looking, and
76
+ # not evidence of two agents at all. Measured 2026-08-09 on this repo: 27
77
+ # raw collisions, of which exactly ONE involved two distinct agent sessions.
78
+ # Reporting 27 would have been a lie of aggregation.
79
+ def _agent_sessions(actors: list) -> int:
80
+ return len([a for a in actors if str(a).startswith("claude:")])
81
+
82
+ confirmed = [c for c in collisions if _agent_sessions(c["actors"]) >= 2]
83
+ ambiguous = [c for c in collisions if _agent_sessions(c["actors"]) < 2]
84
+ total = len(ops)
85
+ multi_actor = len(actors) > 1
86
+ return {
87
+ "ops": total,
88
+ "node_changes": nodes,
89
+ "distinct_nodes": len(touched),
90
+ "actors": dict(actors),
91
+ "actor_count": len(actors),
92
+ "leased_ops": leased,
93
+ "lease_adoption_pct": round(leased * 100 / total, 1) if total else 0.0,
94
+ "languages": dict(langs.most_common(12)),
95
+ "collisions": confirmed or ambiguous,
96
+ "collision_count": len(collisions),
97
+ # The number that actually supports the claim: two DISTINCT agent
98
+ # sessions on one node. Everything else is a candidate, not evidence.
99
+ "confirmed_multi_agent_collisions": len(confirmed),
100
+ "ambiguous_collisions": len(ambiguous),
101
+ # The honesty flag. Every collision number below is meaningless without
102
+ # it, and a reader who does not know that will over-read a zero.
103
+ "can_detect_collisions": multi_actor,
104
+ }
105
+
106
+
107
+ def render(ev: dict) -> str:
108
+ """Human summary. States what CANNOT be concluded as plainly as what can."""
109
+ out = [
110
+ "awgit evidence",
111
+ f" ops captured {ev['ops']}",
112
+ f" node changes {ev['node_changes']} across "
113
+ f"{ev['distinct_nodes']} distinct nodes",
114
+ f" actors {ev['actor_count']} "
115
+ f"({', '.join(list(ev['actors'])[:3]) or 'none'})",
116
+ f" lease adoption {ev['lease_adoption_pct']}% "
117
+ f"({ev['leased_ops']}/{ev['ops']} ops leased)",
118
+ ]
119
+ if ev["languages"]:
120
+ top = ", ".join(f".{k}:{v}" for k, v in list(ev["languages"].items())[:6])
121
+ out.append(f" languages {top}")
122
+ out.append(f" node collisions {ev['collision_count']} raw — "
123
+ f"{ev.get('confirmed_multi_agent_collisions', 0)} CONFIRMED "
124
+ f"(two distinct agent sessions), "
125
+ f"{ev.get('ambiguous_collisions', 0)} ambiguous")
126
+ if not ev["can_detect_collisions"]:
127
+ out.append("")
128
+ out.append(" NOTE: only ONE actor appears in this op-log, so a collision")
129
+ out.append(" is not merely absent — it is INEXPRESSIBLE. Read the count")
130
+ out.append(" as 'not measured', never as 'none happened'.")
131
+ for c in ev["collisions"][:5]:
132
+ out.append(f" {c['path']} {c['node_id'][:16]} {', '.join(c['actors'])}")
133
+ return "\n".join(out)
134
+
135
+
136
+ def to_json(ev: dict) -> str:
137
+ return json.dumps(ev, indent=2, sort_keys=True)