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.
- {awgit-0.3.0 → awgit-0.3.1}/PKG-INFO +1 -1
- {awgit-0.3.0 → awgit-0.3.1}/awgit/capture.py +26 -1
- {awgit-0.3.0 → awgit-0.3.1}/awgit/cli.py +242 -5
- awgit-0.3.1/awgit/evidence.py +137 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/leases.py +47 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/repowise_parser.py +15 -1
- awgit-0.3.1/awgit/staging.py +262 -0
- awgit-0.3.1/awgit/staging_selftest.py +177 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/PKG-INFO +1 -1
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/SOURCES.txt +5 -1
- {awgit-0.3.0 → awgit-0.3.1}/pyproject.toml +1 -1
- {awgit-0.3.0 → awgit-0.3.1}/tests/test_awgit_standalone.py +72 -0
- awgit-0.3.1/tests/test_multilang_identity.py +80 -0
- {awgit-0.3.0 → awgit-0.3.1}/LICENSE +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/README.md +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/__init__.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/bodies.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/bridge.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/data_root.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/diff.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/graph.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/chain.sh +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/identity.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/ledger.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/mcp.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/merge.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/nodeid.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/oplog.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/parser.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/schema.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit/sync.py +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/requires.txt +0 -0
- {awgit-0.3.0 → awgit-0.3.1}/awgit.egg-info/top_level.txt +0 -0
- {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.
|
|
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=
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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]:
|