awgit 0.3.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 (38) hide show
  1. {awgit-0.3.0 → awgit-0.3.1}/PKG-INFO +1 -1
  2. {awgit-0.3.0 → awgit-0.3.1}/awgit/capture.py +26 -1
  3. {awgit-0.3.0 → awgit-0.3.1}/awgit/cli.py +242 -5
  4. awgit-0.3.1/awgit/evidence.py +137 -0
  5. {awgit-0.3.0 → awgit-0.3.1}/awgit/leases.py +47 -0
  6. {awgit-0.3.0 → awgit-0.3.1}/awgit/repowise_parser.py +15 -1
  7. awgit-0.3.1/awgit/staging.py +262 -0
  8. awgit-0.3.1/awgit/staging_selftest.py +177 -0
  9. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/PKG-INFO +1 -1
  10. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/SOURCES.txt +5 -1
  11. {awgit-0.3.0 → awgit-0.3.1}/pyproject.toml +1 -1
  12. {awgit-0.3.0 → awgit-0.3.1}/tests/test_awgit_standalone.py +72 -0
  13. awgit-0.3.1/tests/test_multilang_identity.py +80 -0
  14. {awgit-0.3.0 → awgit-0.3.1}/LICENSE +0 -0
  15. {awgit-0.3.0 → awgit-0.3.1}/README.md +0 -0
  16. {awgit-0.3.0 → awgit-0.3.1}/awgit/__init__.py +0 -0
  17. {awgit-0.3.0 → awgit-0.3.1}/awgit/bodies.py +0 -0
  18. {awgit-0.3.0 → awgit-0.3.1}/awgit/bridge.py +0 -0
  19. {awgit-0.3.0 → awgit-0.3.1}/awgit/data_root.py +0 -0
  20. {awgit-0.3.0 → awgit-0.3.1}/awgit/diff.py +0 -0
  21. {awgit-0.3.0 → awgit-0.3.1}/awgit/graph.py +0 -0
  22. {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/chain.sh +0 -0
  23. {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  24. {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  25. {awgit-0.3.0 → awgit-0.3.1}/awgit/identity.py +0 -0
  26. {awgit-0.3.0 → awgit-0.3.1}/awgit/ledger.py +0 -0
  27. {awgit-0.3.0 → awgit-0.3.1}/awgit/mcp.py +0 -0
  28. {awgit-0.3.0 → awgit-0.3.1}/awgit/merge.py +0 -0
  29. {awgit-0.3.0 → awgit-0.3.1}/awgit/nodeid.py +0 -0
  30. {awgit-0.3.0 → awgit-0.3.1}/awgit/oplog.py +0 -0
  31. {awgit-0.3.0 → awgit-0.3.1}/awgit/parser.py +0 -0
  32. {awgit-0.3.0 → awgit-0.3.1}/awgit/schema.py +0 -0
  33. {awgit-0.3.0 → awgit-0.3.1}/awgit/sync.py +0 -0
  34. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/dependency_links.txt +0 -0
  35. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/entry_points.txt +0 -0
  36. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/requires.txt +0 -0
  37. {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/top_level.txt +0 -0
  38. {awgit-0.3.0 → awgit-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awgit
3
- Version: 0.3.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
@@ -164,6 +164,31 @@ def _ancestor_shas(repo: Path, sha: str) -> List[str]:
164
164
  return out[1:] # first entry is sha itself
165
165
 
166
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
+
167
192
  def _node_records(src: Optional[bytes], rel_path: str) -> List[Dict[str, Any]]:
168
193
  if src is None:
169
194
  return []
@@ -467,7 +492,7 @@ def capture_ops(
467
492
  file_paths=files,
468
493
  node_changes=node_changes,
469
494
  summary=_make_summary(actor_name, node_changes),
470
- leased=False,
495
+ leased=_was_leased(actor_name, files),
471
496
  actor_verified=bool(prov["actor_verified"]),
472
497
  actor_source=str(prov["actor_source"]),
473
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)")
@@ -242,6 +354,15 @@ def _cmd_graph(args: argparse.Namespace) -> int:
242
354
  return 0
243
355
 
244
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
+
245
366
  def _cmd_bodies(args: argparse.Namespace) -> int:
246
367
  from awgit.bodies import BodyStore
247
368
 
@@ -319,24 +440,72 @@ def _cmd_dedupe(args: argparse.Namespace) -> int:
319
440
 
320
441
  def _cmd_ledger(args: argparse.Namespace) -> int:
321
442
  """Attribution view — who changed what, under a verified GitHub identity."""
443
+ import json as _json
444
+
322
445
  from awgit.ledger import op_to_ledger_entry
323
446
  from awgit.oplog import OpLog
324
447
 
325
448
  ops = OpLog().all_ops()
326
449
  if args.op:
327
- 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
328
480
  elif args.sha:
329
481
  ops = [o for o in ops if o.git_sha == args.sha]
330
482
  if not ops:
331
483
  print("vcs: ledger: no ops match", file=sys.stderr)
332
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
+
333
500
  for op in ops:
334
501
  entry = op_to_ledger_entry(op)
335
502
  verified = (
336
503
  f" (verified {entry.verified_actor})" if entry.actor_verified else ""
337
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.
338
507
  print(
339
- f"{entry.ledger_ref} {entry.actor}{verified} "
508
+ f"{entry.ledger_ref} {op.op_id[:16]} {entry.actor}{verified} "
340
509
  f"{entry.git_sha[:10]} {entry.node_changes} node_changes {entry.ts}"
341
510
  )
342
511
  return 0
@@ -403,6 +572,39 @@ def _cmd_hooks(args: argparse.Namespace) -> int:
403
572
  return 2
404
573
 
405
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
+
406
608
  def main(argv: Optional[List[str]] = None) -> int:
407
609
  parser = argparse.ArgumentParser(
408
610
  prog="awgit",
@@ -426,6 +628,10 @@ def main(argv: Optional[List[str]] = None) -> int:
426
628
  sub.add_parser("status", help="op-log status")
427
629
  p_graph = sub.add_parser(
428
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")
429
635
  p_graph.add_argument("--format", choices=("mermaid", "json"),
430
636
  default="mermaid")
431
637
  p_graph.add_argument("--since", default=None,
@@ -453,6 +659,10 @@ def main(argv: Optional[List[str]] = None) -> int:
453
659
  p_la.add_argument("--staged", action="store_true",
454
660
  help="also lease every STAGED file the gate guards "
455
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)")
456
666
  p_la.add_argument("--ttl", type=int, default=300)
457
667
  p_la.add_argument("--reason", default="")
458
668
  p_la.add_argument("--actor", default=None)
@@ -468,6 +678,23 @@ def main(argv: Optional[List[str]] = None) -> int:
468
678
  p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
469
679
  p_lc.add_argument("--actor", default=None)
470
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
+
471
698
  p_bodies = sub.add_parser(
472
699
  "bodies", help="content-addressed body store (read a sha / stats)"
473
700
  )
@@ -496,8 +723,14 @@ def main(argv: Optional[List[str]] = None) -> int:
496
723
  p_ledger = sub.add_parser(
497
724
  "ledger", help="op-log as attribution records (who changed what)"
498
725
  )
499
- 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
+ )
500
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
+ )
501
734
 
502
735
  p_sync = sub.add_parser(
503
736
  "sync", help="differential sync over the mesh (ops + bodies)"
@@ -546,8 +779,12 @@ def main(argv: Optional[List[str]] = None) -> int:
546
779
  return _cmd_lease(args)
547
780
  if args.cmd == "lease-check":
548
781
  return _cmd_lease_check(args)
782
+ if args.cmd == "stage-mine":
783
+ return _cmd_stage_mine(args)
549
784
  if args.cmd == "graph":
550
785
  return _cmd_graph(args)
786
+ if args.cmd == "evidence":
787
+ return _cmd_evidence(args)
551
788
  if args.cmd == "bodies":
552
789
  return _cmd_bodies(args)
553
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)
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import json
18
18
  import logging
19
19
  import os
20
+ import subprocess
20
21
  import uuid
21
22
  from dataclasses import dataclass
22
23
  from datetime import datetime, timedelta, timezone
@@ -47,6 +48,12 @@ class Lease:
47
48
  ttl_sec: int = DEFAULT_TTL_SEC
48
49
  reason: str = ""
49
50
  status: str = "active" # active | expired | revoked | released
51
+ #: Content of the file at the moment the lease was granted, as a git blob sha.
52
+ #: This is what makes "which edits are MINE" computable rather than guessable:
53
+ #: my edits are exactly (working tree - baseline), and everything else in the
54
+ #: file belongs to whoever else is writing it. Empty for node leases and for
55
+ #: paths that did not exist or could not be hashed.
56
+ baseline_blob: str = ""
50
57
 
51
58
  def to_dict(self) -> Dict[str, object]:
52
59
  return {
@@ -60,6 +67,7 @@ class Lease:
60
67
  "ttl_sec": self.ttl_sec,
61
68
  "reason": self.reason,
62
69
  "status": self.status,
70
+ "baseline_blob": self.baseline_blob,
63
71
  }
64
72
 
65
73
  @classmethod
@@ -75,9 +83,45 @@ class Lease:
75
83
  ttl_sec=int(d.get("ttl_sec", DEFAULT_TTL_SEC)),
76
84
  reason=str(d.get("reason", "")),
77
85
  status=str(d.get("status", "active")),
86
+ baseline_blob=str(d.get("baseline_blob", "")),
78
87
  )
79
88
 
80
89
 
90
+ def snapshot_baseline(rel_path: str, repo: Optional[Path] = None) -> str:
91
+ """Store the file's current bytes as a git blob and return its sha.
92
+
93
+ Uses ``git hash-object -w`` so the snapshot lives in the object database the
94
+ repo already has — no parallel content store to grow, prune or corrupt, and
95
+ the blob is readable later with ``git cat-file``.
96
+
97
+ Returns "" when there is nothing to snapshot (the path does not exist yet, or
98
+ git is unavailable). An empty baseline is handled explicitly downstream rather
99
+ than being treated as an empty FILE, because those mean opposite things: "I
100
+ have no record" must not silently become "the file was empty, so everything in
101
+ it is yours".
102
+ """
103
+ root = repo or Path.cwd()
104
+ target = root / rel_path
105
+ if not target.is_file():
106
+ return ""
107
+ try:
108
+ proc = subprocess.run(
109
+ ["git", "hash-object", "-w", "--", rel_path],
110
+ cwd=root, capture_output=True, text=True,
111
+ encoding="utf-8", errors="replace", timeout=30,
112
+ )
113
+ except (OSError, subprocess.TimeoutExpired) as exc:
114
+ logger.warning("[vcs.leases] baseline snapshot failed for %s: %s", rel_path, exc)
115
+ return ""
116
+ if proc.returncode != 0:
117
+ logger.warning(
118
+ "[vcs.leases] baseline snapshot failed for %s: %s",
119
+ rel_path, (proc.stderr or "").strip(),
120
+ )
121
+ return ""
122
+ return proc.stdout.strip()
123
+
124
+
81
125
  class LeaseConflictError(Exception):
82
126
  """All-or-nothing acquire collided with another actor's active lease."""
83
127
 
@@ -172,6 +216,9 @@ class LeaseRegistry:
172
216
  heartbeat_ts=now,
173
217
  ttl_sec=ttl_sec,
174
218
  reason=reason,
219
+ # Snapshot NOW, before any edit — this is the whole point
220
+ # of taking the lease before you start typing.
221
+ baseline_blob=(snapshot_baseline(t) if k == "path" else ""),
175
222
  )
176
223
  self._leases[lz.lease_id] = lz
177
224
  granted.append(lz)
@@ -49,6 +49,17 @@ def available() -> Tuple[bool, str]:
49
49
  return False, _UNAVAILABLE
50
50
 
51
51
 
52
+ # Languages repowise names but which carry no CODE NODES — documents, data and
53
+ # markup. Admitting them made capture parse every .md/.json/.yaml commit to
54
+ # produce zero symbols, and worse, made "no op recorded" ambiguous: a doc commit
55
+ # and a genuinely-missed code commit looked identical, which is how a coverage
56
+ # figure of 47% got misread as half the commits being dropped.
57
+ SYMBOL_LESS_LANGUAGES = {
58
+ "markdown", "asciidoc", "json", "yaml", "toml", "ini", "csv", "text",
59
+ "html", "css", "xml", "sql",
60
+ }
61
+
62
+
52
63
  def language_for(path: str) -> Optional[str]:
53
64
  """repowise's language for a path, or None when it does not handle it."""
54
65
  ok, _ = available()
@@ -59,7 +70,10 @@ def language_for(path: str) -> Optional[str]:
59
70
  except Exception:
60
71
  return None
61
72
  _, _, ext = (path or "").rpartition(".")
62
- return EXTENSION_TO_LANGUAGE.get("." + ext.lower()) if ext else None
73
+ if not ext:
74
+ return None
75
+ lang = EXTENSION_TO_LANGUAGE.get("." + ext.lower())
76
+ return None if lang in SYMBOL_LESS_LANGUAGES else lang
63
77
 
64
78
 
65
79
  def parse_symbols(content: bytes, path: str) -> List[dict]: