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.
- {awgit-0.2.0 → awgit-0.3.1}/PKG-INFO +3 -1
- {awgit-0.2.0 → awgit-0.3.1}/awgit/capture.py +68 -2
- {awgit-0.2.0 → awgit-0.3.1}/awgit/cli.py +272 -5
- awgit-0.3.1/awgit/evidence.py +137 -0
- awgit-0.3.1/awgit/graph.py +133 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/leases.py +47 -0
- awgit-0.3.1/awgit/repowise_parser.py +121 -0
- awgit-0.3.1/awgit/staging.py +262 -0
- awgit-0.3.1/awgit/staging_selftest.py +177 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/PKG-INFO +3 -1
- {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/SOURCES.txt +7 -1
- awgit-0.3.1/awgit.egg-info/requires.txt +4 -0
- {awgit-0.2.0 → awgit-0.3.1}/pyproject.toml +9 -1
- {awgit-0.2.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.2.0/awgit.egg-info/requires.txt +0 -1
- {awgit-0.2.0 → awgit-0.3.1}/LICENSE +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/README.md +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/__init__.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/bodies.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/bridge.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/data_root.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/diff.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/chain.sh +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/identity.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/ledger.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/mcp.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/merge.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/nodeid.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/oplog.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/parser.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/schema.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit/sync.py +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-0.2.0 → awgit-0.3.1}/awgit.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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)")
|
|
@@ -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
|
-
|
|
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(
|
|
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)
|