awgit 1.1.2__tar.gz → 1.2.0__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 (71) hide show
  1. awgit-1.1.2/README.md → awgit-1.2.0/PKG-INFO +17 -34
  2. awgit-1.1.2/awgit.egg-info/PKG-INFO → awgit-1.2.0/README.md +0 -49
  3. {awgit-1.1.2 → awgit-1.2.0}/awgit/cli.py +258 -97
  4. {awgit-1.1.2 → awgit-1.2.0}/awgit/evidence.py +20 -0
  5. awgit-1.2.0/awgit/lease_requests.py +171 -0
  6. awgit-1.2.0/awgit/outcomes.py +250 -0
  7. {awgit-1.1.2 → awgit-1.2.0}/awgit/plugins.py +18 -0
  8. {awgit-1.1.2 → awgit-1.2.0}/awgit/schema.py +92 -0
  9. {awgit-1.1.2 → awgit-1.2.0}/awgit/worktree.py +1 -1
  10. {awgit-1.1.2 → awgit-1.2.0/awgit.egg-info}/PKG-INFO +5 -37
  11. {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/SOURCES.txt +5 -1
  12. {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/requires.txt +0 -3
  13. {awgit-1.1.2 → awgit-1.2.0}/pyproject.toml +11 -7
  14. awgit-1.2.0/tests/test_lease_release_accepts_paths.py +142 -0
  15. awgit-1.2.0/tests/test_lease_requests.py +156 -0
  16. awgit-1.2.0/tests/test_outcome_tracking.py +410 -0
  17. awgit-1.1.2/tests/test_tabular.py +0 -273
  18. {awgit-1.1.2 → awgit-1.2.0}/LICENSE +0 -0
  19. {awgit-1.1.2 → awgit-1.2.0}/awgit/__init__.py +0 -0
  20. {awgit-1.1.2 → awgit-1.2.0}/awgit/absorb.py +0 -0
  21. {awgit-1.1.2 → awgit-1.2.0}/awgit/bodies.py +0 -0
  22. {awgit-1.1.2 → awgit-1.2.0}/awgit/bridge.py +0 -0
  23. {awgit-1.1.2 → awgit-1.2.0}/awgit/capture.py +0 -0
  24. {awgit-1.1.2 → awgit-1.2.0}/awgit/changeid.py +0 -0
  25. {awgit-1.1.2 → awgit-1.2.0}/awgit/code.py +0 -0
  26. {awgit-1.1.2 → awgit-1.2.0}/awgit/commands.py +0 -0
  27. {awgit-1.1.2 → awgit-1.2.0}/awgit/data_root.py +0 -0
  28. {awgit-1.1.2 → awgit-1.2.0}/awgit/diff.py +0 -0
  29. {awgit-1.1.2 → awgit-1.2.0}/awgit/git.py +0 -0
  30. {awgit-1.1.2 → awgit-1.2.0}/awgit/graph.py +0 -0
  31. {awgit-1.1.2 → awgit-1.2.0}/awgit/guard.py +0 -0
  32. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/chain.sh +0 -0
  33. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  34. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  35. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-commit.d/vcs-mass-delete-guard +0 -0
  36. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-push.d/ci-gate-parity +0 -0
  37. {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/prepare-commit-msg.d/awgit-change-id +0 -0
  38. {awgit-1.1.2 → awgit-1.2.0}/awgit/identity.py +0 -0
  39. {awgit-1.1.2 → awgit-1.2.0}/awgit/lazy.py +0 -0
  40. {awgit-1.1.2 → awgit-1.2.0}/awgit/leases.py +0 -0
  41. {awgit-1.1.2 → awgit-1.2.0}/awgit/ledger.py +0 -0
  42. {awgit-1.1.2 → awgit-1.2.0}/awgit/mcp.py +0 -0
  43. {awgit-1.1.2 → awgit-1.2.0}/awgit/merge.py +0 -0
  44. {awgit-1.1.2 → awgit-1.2.0}/awgit/nodeid.py +0 -0
  45. {awgit-1.1.2 → awgit-1.2.0}/awgit/oplog.py +0 -0
  46. {awgit-1.1.2 → awgit-1.2.0}/awgit/owners.py +0 -0
  47. {awgit-1.1.2 → awgit-1.2.0}/awgit/parser.py +0 -0
  48. {awgit-1.1.2 → awgit-1.2.0}/awgit/prove.py +0 -0
  49. {awgit-1.1.2 → awgit-1.2.0}/awgit/push.py +0 -0
  50. {awgit-1.1.2 → awgit-1.2.0}/awgit/repowise_parser.py +0 -0
  51. {awgit-1.1.2 → awgit-1.2.0}/awgit/review.py +0 -0
  52. {awgit-1.1.2 → awgit-1.2.0}/awgit/stack.py +0 -0
  53. {awgit-1.1.2 → awgit-1.2.0}/awgit/staging.py +0 -0
  54. {awgit-1.1.2 → awgit-1.2.0}/awgit/staging_selftest.py +0 -0
  55. {awgit-1.1.2 → awgit-1.2.0}/awgit/sync.py +0 -0
  56. {awgit-1.1.2 → awgit-1.2.0}/awgit/tabular.py +0 -0
  57. {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/dependency_links.txt +0 -0
  58. {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/entry_points.txt +0 -0
  59. {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/top_level.txt +0 -0
  60. {awgit-1.1.2 → awgit-1.2.0}/setup.cfg +0 -0
  61. {awgit-1.1.2 → awgit-1.2.0}/tests/test_absorb_routes_by_node.py +0 -0
  62. {awgit-1.1.2 → awgit-1.2.0}/tests/test_awgit_standalone.py +0 -0
  63. {awgit-1.1.2 → awgit-1.2.0}/tests/test_changeid_survives_history_rewrite.py +0 -0
  64. {awgit-1.1.2 → awgit-1.2.0}/tests/test_documented_commands_run.py +0 -0
  65. {awgit-1.1.2 → awgit-1.2.0}/tests/test_lazy_clone_is_verified.py +0 -0
  66. {awgit-1.1.2 → awgit-1.2.0}/tests/test_multilang_identity.py +0 -0
  67. {awgit-1.1.2 → awgit-1.2.0}/tests/test_owners_and_proof.py +0 -0
  68. {awgit-1.1.2 → awgit-1.2.0}/tests/test_push_is_the_pull_request.py +0 -0
  69. {awgit-1.1.2 → awgit-1.2.0}/tests/test_restack_repairs_orphans.py +0 -0
  70. {awgit-1.1.2 → awgit-1.2.0}/tests/test_review_threads_survive_moves.py +0 -0
  71. {awgit-1.1.2 → awgit-1.2.0}/tests/test_worktree_zombie_detection.py +0 -0
@@ -1,3 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: awgit
3
+ Version: 1.2.0
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
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/Aitherium/awgit
7
+ Project-URL: Documentation, https://aitherium.github.io/awgit/
8
+ Project-URL: Repository, https://github.com/Aitherium/awgit.git
9
+ Project-URL: Issues, https://github.com/Aitherium/awgit/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx>=0.25.0
14
+ Provides-Extra: multilang
15
+ Requires-Dist: repowise>=0.31.0; extra == "multilang"
16
+ Dynamic: license-file
17
+
1
18
  # awgit — the git that knows a function from a line
2
19
 
3
20
  Git has no world model. It knows which lines moved, not what a function is — so
@@ -190,42 +207,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
190
207
  awgit evidence # the measurable claim, from your own op-log
191
208
  awgit bodies --get <sha> # read a body from the content-addressed store
192
209
  awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
193
- awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
194
210
  ```
195
211
 
196
- ### Data files get the same treatment as code
197
-
198
- A line diff is useless on a table: sort it and every line "changed"; reorder two
199
- rows and a review drowns. So a row gets the same pair a function gets — an
200
- **identity** and a **content address**:
201
-
202
- ```
203
- row identity = H(the --key columns) # which row is this?
204
- row content = H(every column) # has it changed?
205
- ```
206
-
207
- The diff is then set algebra on identity, so rows can be reordered freely and
208
- nothing is reported:
209
-
210
- ```bash
211
- awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
212
- awgit data diff old.csv new.csv --key id --json # before/after per modified row
213
- ```
214
-
215
- Without `--key` there is no identity, so it degrades to a content set-diff and
216
- **says so** — every edit reads as an add plus a remove, and `modified` stays
217
- empty rather than being guessed at. A key column that exists in neither table is
218
- an error, not an empty result: silently keying on a missing column would report
219
- every row as added *and* removed, which looks exactly like data loss.
220
-
221
- CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
222
- `pip install awgit[tabular]`.
223
-
224
- *The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
225
- (Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
226
- differs — it length-prefixes each field so that two different rows cannot share
227
- a content address — so hashes are not comparable between the two tools.*
228
-
229
212
  - **Merge** at node granularity: disjoint node sets merge clean by
230
213
  construction, and a genuine collision escalates naming the exact function
231
214
  (`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: awgit
3
- Version: 1.1.2
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
- License: Apache-2.0
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: httpx>=0.25.0
10
- Provides-Extra: multilang
11
- Requires-Dist: repowise>=0.31.0; extra == "multilang"
12
- Provides-Extra: tabular
13
- Requires-Dist: pyarrow>=15.0.0; extra == "tabular"
14
- Dynamic: license-file
15
-
16
1
  # awgit — the git that knows a function from a line
17
2
 
18
3
  Git has no world model. It knows which lines moved, not what a function is — so
@@ -205,42 +190,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
205
190
  awgit evidence # the measurable claim, from your own op-log
206
191
  awgit bodies --get <sha> # read a body from the content-addressed store
207
192
  awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
208
- awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
209
193
  ```
210
194
 
211
- ### Data files get the same treatment as code
212
-
213
- A line diff is useless on a table: sort it and every line "changed"; reorder two
214
- rows and a review drowns. So a row gets the same pair a function gets — an
215
- **identity** and a **content address**:
216
-
217
- ```
218
- row identity = H(the --key columns) # which row is this?
219
- row content = H(every column) # has it changed?
220
- ```
221
-
222
- The diff is then set algebra on identity, so rows can be reordered freely and
223
- nothing is reported:
224
-
225
- ```bash
226
- awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
227
- awgit data diff old.csv new.csv --key id --json # before/after per modified row
228
- ```
229
-
230
- Without `--key` there is no identity, so it degrades to a content set-diff and
231
- **says so** — every edit reads as an add plus a remove, and `modified` stays
232
- empty rather than being guessed at. A key column that exists in neither table is
233
- an error, not an empty result: silently keying on a missing column would report
234
- every row as added *and* removed, which looks exactly like data loss.
235
-
236
- CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
237
- `pip install awgit[tabular]`.
238
-
239
- *The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
240
- (Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
241
- differs — it length-prefixes each field so that two different rows cannot share
242
- a content address — so hashes are not comparable between the two tools.*
243
-
244
195
  - **Merge** at node granularity: disjoint node sets merge clean by
245
196
  construction, and a genuine collision escalates naming the exact function
246
197
  (`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
@@ -91,48 +91,19 @@ def _cmd_capture(args: argparse.Namespace) -> int:
91
91
  print(f"vcs: no semantic changes for {args.sha}")
92
92
  return 0
93
93
  print(f"vcs: op {op.op_id} recorded ({op.summary})")
94
- return 0
95
-
96
-
97
- def _cmd_data(args: argparse.Namespace) -> int:
98
- """Row-level diff of two tabular files (see awgit/tabular.py)."""
99
- import json # function-local, matching the convention in this module
100
-
101
- from . import tabular
102
-
103
- if args.data_cmd != "diff": # pragma: no cover - argparse enforces this
104
- print(f"awgit: unknown data subcommand {args.data_cmd!r}", file=sys.stderr)
105
- return 2
106
-
107
- try:
108
- d = tabular.diff_files(args.old, args.new, args.key)
109
- except tabular.UnreadableTableError as exc:
110
- # Exit 2, never 0-with-empty-output: a table we could not read must not
111
- # be reported as a table with no differences.
112
- print(f"awgit: {exc}", file=sys.stderr)
113
- return 2
114
94
 
115
- if args.as_json:
116
- print(json.dumps({
117
- "summary": d.summary(),
118
- "added": d.added,
119
- "removed": d.removed,
120
- "modified": [{"before": b, "after": a} for b, a in d.modified],
121
- }, indent=2, default=str))
122
- return 0
95
+ # Record proof of verification if requested
96
+ if getattr(args, "prove", False):
97
+ try:
98
+ from awgit.outcomes import record_outcome
99
+ from awgit.prove import run_gates
100
+ data_root = Path(args.data_root) if args.data_root else None
101
+ gates = run_gates(op.file_paths)
102
+ outcome = record_outcome(args.sha, gates, data_root=data_root)
103
+ print(f"vcs: outcome {outcome.outcome_id} recorded ({outcome.verdict})")
104
+ except Exception as exc:
105
+ print(f"vcs: proof recording failed (non-fatal): {exc}", file=sys.stderr)
123
106
 
124
- s = d.summary()
125
- if d.keyless:
126
- print("no --key given: content set-diff only; MODIFIED rows cannot be "
127
- "distinguished from an add plus a remove.")
128
- else:
129
- print(f"keyed on: {', '.join(d.keys)}")
130
- for col in s["columns_added"]:
131
- print(f" + column {col}")
132
- for col in s["columns_removed"]:
133
- print(f" - column {col}")
134
- print(f" {s['added']} added, {s['removed']} removed, "
135
- f"{s['modified']} modified, {s['unchanged']} unchanged")
136
107
  return 0
137
108
 
138
109
 
@@ -232,10 +203,45 @@ def _cmd_status(args: argparse.Namespace) -> int:
232
203
  return 0
233
204
 
234
205
 
206
+ def _resolve_lease_args(registry, who, raw):
207
+ """Map release/heartbeat arguments to lease ids, accepting leased PATHS too.
208
+
209
+ `acquire` takes paths and `release` takes ids, so passing the same string to
210
+ both is the natural mistake -- and it used to be a silent one, because an
211
+ unmatched id simply released nothing and still printed success.
212
+
213
+ Returns (ids, unresolved). Anything that is neither one of this actor's
214
+ active lease ids nor one of its leased targets comes back in `unresolved`,
215
+ so the caller can fail rather than report a count of zero.
216
+ """
217
+ mine = [lz for lz in registry.active_leases() if lz.actor == who]
218
+ by_id = {lz.lease_id: lz.lease_id for lz in mine}
219
+ by_target = {}
220
+ for lz in mine:
221
+ # Last writer wins is fine: releasing any lease on that path is the
222
+ # intent, and a duplicate target for one actor is already a bug.
223
+ by_target[str(lz.target).replace("\\", "/").strip("/")] = lz.lease_id
224
+ ids, unresolved = [], []
225
+ for arg in raw or []:
226
+ if arg in by_id:
227
+ ids.append(arg)
228
+ continue
229
+ key = str(arg).replace("\\", "/").strip("/")
230
+ if key in by_target:
231
+ ids.append(by_target[key])
232
+ continue
233
+ unresolved.append(arg)
234
+ return ids, unresolved
235
+
236
+
235
237
  def _cmd_lease(args: argparse.Namespace) -> int:
236
238
  registry = LeaseRegistry()
237
239
  cmd = args.lease_cmd
238
240
  who = _actor(args)
241
+ if cmd == "contact":
242
+ return _cmd_lease_contact(args)
243
+ if cmd == "requests":
244
+ return _cmd_lease_requests(args)
239
245
  if cmd == "acquire":
240
246
  targets = list(args.targets or [])
241
247
  if getattr(args, "staged", False):
@@ -290,7 +296,19 @@ def _cmd_lease(args: argparse.Namespace) -> int:
290
296
  who, targets, ttl_sec=args.ttl, reason=args.reason
291
297
  )
292
298
  except LeaseConflictError as exc:
299
+ # Flush stdout FIRST. It is block-buffered when redirected to a file
300
+ # or a pipe and stderr is not, so this line otherwise lands
301
+ # INTERLEAVED in the middle of whatever stdout had buffered rather
302
+ # than at the end where anyone looks. Measured 2026-08-19:
303
+ # `lease acquire --staged --adopt` over 1776 files refused correctly
304
+ # on a real conflict, and the one line saying why was glued onto the
305
+ # middle of the adoption list at line 1657 of 1778 -- head and tail
306
+ # both missed it, and a correct refusal got reported as "awgit exits
307
+ # 1 and persists nothing". A diagnostic nobody can find is worse than
308
+ # none: it gets diagnosed as a different bug.
309
+ sys.stdout.flush()
293
310
  print(f"vcs: {exc}", file=sys.stderr)
311
+ sys.stderr.flush()
294
312
  return 1
295
313
  # A lease over an ALREADY-DIRTY file captures a baseline that contains work
296
314
  # which is not yours, and `stage-mine` computes (baseline -> worktree), so it
@@ -316,11 +334,28 @@ def _cmd_lease(args: argparse.Namespace) -> int:
316
334
  "`git diff --stat -- <path>` must match the size of YOUR edit.",
317
335
  file=sys.stderr)
318
336
  return 0
319
- if cmd == "heartbeat":
320
- print(f"vcs: heartbeat refreshed {registry.heartbeat(who, args.ids)} leases")
321
- return 0
322
- if cmd == "release":
323
- print(f"vcs: released {registry.release(who, args.ids)} leases")
337
+ if cmd in ("heartbeat", "release"):
338
+ # `release`/`heartbeat` take lease IDS. Passing a PATH -- the same string
339
+ # `acquire` takes, and the obvious guess -- matched no id, so the registry
340
+ # returned 0 and this printed "released 0 leases" and exited 0 while the
341
+ # lease sat there in `lease list`. A command that reports success for
342
+ # having done nothing is the silent-no-op class in
343
+ # .claude/rules/security-review-patterns.md #5, and it cost a session on
344
+ # 2026-08-16: the release "succeeded", the lease stayed held, and the next
345
+ # edit was blocked by the caller's own lease.
346
+ # Resolve path-shaped arguments against this actor's active leases, and
347
+ # refuse anything that resolves to nothing rather than reporting 0.
348
+ ids, unresolved = _resolve_lease_args(registry, who, args.ids)
349
+ if unresolved:
350
+ print("vcs: no active lease of yours matches: " + ", ".join(unresolved),
351
+ file=sys.stderr)
352
+ print("vcs: pass a lease id or a leased path (`awgit lease list`)",
353
+ file=sys.stderr)
354
+ return 1
355
+ if cmd == "heartbeat":
356
+ print(f"vcs: heartbeat refreshed {registry.heartbeat(who, ids)} leases")
357
+ else:
358
+ print(f"vcs: released {registry.release(who, ids)} leases")
324
359
  return 0
325
360
  if cmd == "list":
326
361
  for lz in sorted(registry.active_leases(), key=lambda x: x.target):
@@ -411,6 +446,28 @@ def staged_but_not_committed(repo: Path) -> List[str]:
411
446
  except OSError:
412
447
  return []
413
448
 
449
+ # An index the OPERATOR supplied is not the sweep this rule is about — it is
450
+ # the documented DEFENCE against it (concurrent-safe-git rule 1a: seed a
451
+ # private index from HEAD so a peer's staging cannot reach your commit). It
452
+ # was being rejected by the very gate that recommends it, and the rejection
453
+ # message told the committer to do what they were already doing, so the only
454
+ # ways forward were --no-verify or the sweep. Measured 2026-08-15 on a commit
455
+ # whose alternative was shipping a peer's half-finished route-manifest
456
+ # refactor that DELETES three RBAC mappings — i.e. the gate was pushing
457
+ # toward the exact outcome it exists to prevent.
458
+ #
459
+ # The discriminator is WHERE the index lives, and it cannot be evaded:
460
+ # `git commit -- <pathspec>` and `git commit -a` build their temp index
461
+ # INSIDE the git dir (`next-index-<pid>.lock`, `index.lock`), and they do so
462
+ # even when GIT_INDEX_FILE is already set to something else — verified
463
+ # against a real throwaway repo, both forms, with and without a private
464
+ # index exported. So an index outside the git dir can only have come from
465
+ # the operator, and what they are committing is what they chose.
466
+ try:
467
+ Path(temp_index).resolve().relative_to(Path(git_dir).resolve())
468
+ except (ValueError, OSError):
469
+ return [] # operator-supplied private index — rule 1a, not a sweep
470
+
414
471
  # The paths AT RISK are the ones really staged — `_staged_files` would read
415
472
  # the temp index here, for the same GIT_INDEX_FILE reason as above.
416
473
  paths = [
@@ -517,48 +574,155 @@ def _cmd_stage_mine(args: argparse.Namespace) -> int:
517
574
  return 1 if failed else 0
518
575
 
519
576
 
520
- def _merge_hand_resolved(repo: Path, staged: List[str]) -> List[str]:
521
- """During a merge, the files the COMMITTER actually decided.
577
+ def merge_authored_files(repo: Path, staged: List[str]) -> List[str]:
578
+ """During a MERGE, the files the committer actually authored.
579
+
580
+ A merge commit brings in every file the other side changed — already-committed
581
+ history that no lease could sensibly cover. The lease plane exists to stop one
582
+ session clobbering another's UNCOMMITTED work, and a merge cannot do that: git
583
+ refuses to merge over dirty files it would overwrite. So demanding a lease for
584
+ incoming history is asking for something that is neither possible nor useful.
522
585
 
523
- A merge commit legitimately stages everything the other lineage touched. On
524
- this repo that is 1,266 files for a single recovery merge, and demanding a
525
- lease on each is not a safety property -- it is a wall. Measured 2026-08-19:
526
- `lease acquire --staged --adopt` granted zero, and acquiring them in batches
527
- ran past six minutes without finishing, so a legitimate merge could not be
528
- recorded at all.
586
+ Measured 2026-08-11: merging origin/develop into a feature branch demanded
587
+ leases for ~250 files, and `lease acquire --staged --adopt` could only pick up
588
+ 7 because is_guarded() filters the rest — leaving no way to complete a merge
589
+ except switching enforcement off, which is exactly how a gate stops being used.
529
590
 
530
- The gate exists to stop one session sweeping another's IN-FLIGHT edit. A file
531
- taken verbatim from either parent is nobody's in-flight edit -- git chose it,
532
- not the committer. Only a file whose staged blob differs from BOTH parents was
533
- hand-resolved, and those are exactly the committer's own work, so those are
534
- what still require a lease.
591
+ What IS still guarded: the conflict RESOLUTIONS. A staged blob that matches
592
+ neither parent is text the committer wrote by hand, and that is a real edit on
593
+ a shared file. Everything taken verbatim from either side is inherited.
535
594
 
536
- Returns the staged paths when this is not a merge, so the normal path is
537
- unchanged.
595
+ Returns `staged` unchanged when this is not a merge.
538
596
  """
539
- git_dir = subprocess.run(
540
- ["git", "rev-parse", "--git-dir"], cwd=str(repo),
541
- capture_output=True, text=True, encoding="utf-8", errors="replace",
542
- ).stdout.strip()
543
- if not git_dir or not (Path(repo) / git_dir / "MERGE_HEAD").exists() and not Path(git_dir, "MERGE_HEAD").exists():
597
+ try:
598
+ git_dir = subprocess.run(
599
+ ["git", "rev-parse", "--absolute-git-dir"], cwd=str(repo),
600
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
601
+ ).stdout.strip()
602
+ except OSError:
603
+ return staged
604
+ if not git_dir or not (Path(git_dir) / "MERGE_HEAD").is_file():
544
605
  return staged
545
606
 
546
- def _differs(ref: str) -> set:
607
+ def blobs(rev: str) -> dict:
547
608
  out = subprocess.run(
548
- ["git", "diff", "--cached", "--name-only", ref], cwd=str(repo),
609
+ ["git", "ls-tree", "-r", rev, "--", *staged], cwd=str(repo),
549
610
  capture_output=True, text=True, encoding="utf-8", errors="replace",
550
611
  ).stdout
551
- return {ln for ln in out.splitlines() if ln.strip()}
552
-
553
- ours, theirs = _differs("HEAD"), _differs("MERGE_HEAD")
554
- if not ours and not theirs:
555
- # Could not read either parent -> judge nothing away; fall back to the
556
- # full staged set rather than exempting everything.
612
+ found = {}
613
+ for line in out.splitlines():
614
+ meta, _, path = line.partition(" ")
615
+ parts = meta.split()
616
+ if len(parts) >= 3 and path:
617
+ found[path] = parts[2]
618
+ return found
619
+
620
+ if not staged:
557
621
  return staged
558
- hand = sorted(set(staged) & ours & theirs)
559
- print(f"vcs: merge in progress -- {len(staged)} staged, {len(hand)} hand-resolved; "
560
- f"a lease is required on the hand-resolved files only")
561
- return hand
622
+ ours, theirs = blobs("HEAD"), blobs("MERGE_HEAD")
623
+ index = {}
624
+ out = subprocess.run(
625
+ ["git", "ls-files", "--stage", "--", *staged], cwd=str(repo),
626
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
627
+ ).stdout
628
+ for line in out.splitlines():
629
+ meta, _, path = line.partition(" ")
630
+ parts = meta.split()
631
+ if len(parts) >= 2 and path:
632
+ index[path] = parts[1]
633
+
634
+ authored = [p for p in staged
635
+ if index.get(p) not in (ours.get(p), theirs.get(p))]
636
+ return authored
637
+
638
+
639
+ def _requests_store():
640
+ """(LeaseRequests, registry) or (None, None) if the plane is unreadable."""
641
+ try:
642
+ from .lease_requests import LeaseRequests
643
+ from .leases import LeaseRegistry, vcs_data_root
644
+ reg = LeaseRegistry()
645
+ return LeaseRequests(vcs_data_root()), reg
646
+ except Exception as exc: # noqa: BLE001 - never fatal
647
+ print(f"vcs: lease-request store unavailable ({exc})", file=sys.stderr)
648
+ return None, None
649
+
650
+
651
+ def _relay_notify(to_actor: str, target: str, message: str) -> bool:
652
+ """Best-effort async ping. NEVER the only delivery path.
653
+
654
+ Returns False when it could not send, so the caller can say so rather than
655
+ implying the holder was reached. Relay is optional here on purpose: if the
656
+ only notification depended on a running service, this feature would be
657
+ unavailable in exactly the degraded conditions that produce lease pile-ups.
658
+ """
659
+ try:
660
+ from awrelay.client import RelayClient # type: ignore
661
+ except ImportError:
662
+ return False
663
+ try:
664
+ who = to_actor.split(":")[-1][:8]
665
+ RelayClient().send(
666
+ channel="lease-negotiation",
667
+ text=f"@{who} please release `{target}` — {message or 'another '
668
+ 'session is blocked on it'}")
669
+ return True
670
+ except Exception: # noqa: BLE001
671
+ return False
672
+
673
+
674
+ def _cmd_lease_contact(args) -> int:
675
+ """Ask whoever holds a path's lease to let it go."""
676
+ store, reg = _requests_store()
677
+ if store is None or reg is None:
678
+ return 2
679
+ me = _actor(args)
680
+ target = args.path
681
+
682
+ holder = None
683
+ for lz in reg.active_leases():
684
+ if lz.target == target:
685
+ holder = lz
686
+ break
687
+ if holder is None:
688
+ print(f"vcs: no active lease on {target!r} — nothing to ask for. "
689
+ f"If a commit was refused, re-run `awgit lease acquire`.")
690
+ return 0
691
+ if holder.actor == me:
692
+ print(f"vcs: {target!r} is held by YOU ({me}); "
693
+ f"`awgit lease release {holder.lease_id}` frees it.")
694
+ return 0
695
+
696
+ store.add(target, holder.actor, me, getattr(args, "message", "") or "")
697
+ relayed = _relay_notify(holder.actor, target,
698
+ getattr(args, "message", "") or "")
699
+ print(f"vcs: asked {holder.actor} to release {target!r} "
700
+ f"(expires {holder.expires_ts}).")
701
+ print("vcs: they see it on their next `awgit lease list`"
702
+ + (" and on relay #lease-negotiation." if relayed
703
+ else " (relay unavailable — the awgit path still delivers)."))
704
+ return 0
705
+
706
+
707
+ def _cmd_lease_requests(args) -> int:
708
+ """What am I blocking, and what am I waiting on?"""
709
+ store, _ = _requests_store()
710
+ if store is None:
711
+ return 2
712
+ me = _actor(args)
713
+ from .lease_requests import format_pending
714
+
715
+ incoming = store.for_actor(me)
716
+ outgoing = store.by_actor(me)
717
+ if incoming:
718
+ print(format_pending(incoming))
719
+ else:
720
+ print("vcs: nobody is blocked on leases you hold.")
721
+ if outgoing:
722
+ print(f"vcs: you have asked for {len(outgoing)} lease(s):")
723
+ for r in outgoing[:10]:
724
+ print(f"vcs: {r.get('target')} <- {str(r.get('to_actor'))[:24]}")
725
+ return 0
562
726
 
563
727
 
564
728
  def _cmd_lease_check(args: argparse.Namespace) -> int:
@@ -570,7 +734,7 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
570
734
  if who == "unknown":
571
735
  print("vcs: lease-check requires AITHER_ACTOR (or --actor)", file=sys.stderr)
572
736
  return 1
573
- gap = coverage_gap(_merge_hand_resolved(repo, _staged_files(repo)), who)
737
+ gap = coverage_gap(merge_authored_files(repo, _staged_files(repo)), who)
574
738
  if gap:
575
739
  print(
576
740
  "vcs: commit rejected — no active lease covering: " + ", ".join(gap),
@@ -1477,29 +1641,17 @@ def build_parser() -> argparse.ArgumentParser:
1477
1641
  default=None,
1478
1642
  help="vcs store directory (default: ~/.aither/awgit/data, or $VCS_DATA_ROOT)",
1479
1643
  )
1644
+ p_capture.add_argument(
1645
+ "--prove",
1646
+ action="store_true",
1647
+ help="run gates and record the outcome after capture",
1648
+ )
1480
1649
 
1481
1650
  p_diff = sub.add_parser("diff", help="node-level diff between two shas")
1482
1651
  p_diff.add_argument("a", help="base sha")
1483
1652
  p_diff.add_argument("b", help="target sha")
1484
1653
  p_diff.add_argument("--json", action="store_true", dest="as_json")
1485
1654
 
1486
- # `data` is its own verb rather than an overload of `diff`: `awgit diff`
1487
- # means NODE diff and keeps that meaning (the MCP handler, two skills, the
1488
- # hooks and two blog posts all depend on its shape), so a tabular diff gets
1489
- # its own noun instead of silently changing an existing contract.
1490
- p_data = sub.add_parser("data", help="row-level operations on tabular files")
1491
- data_sub = p_data.add_subparsers(dest="data_cmd", required=True)
1492
- p_data_diff = data_sub.add_parser(
1493
- "diff", help="row-level diff of two CSV/TSV/parquet files")
1494
- p_data_diff.add_argument("old", help="baseline table")
1495
- p_data_diff.add_argument("new", help="target table")
1496
- p_data_diff.add_argument(
1497
- "--key", action="append", default=[], metavar="COL",
1498
- help="key column giving each row its identity; repeatable. Without one "
1499
- "the diff falls back to a content set-diff and cannot report "
1500
- "MODIFIED rows.")
1501
- p_data_diff.add_argument("--json", action="store_true", dest="as_json")
1502
-
1503
1655
  p_status = sub.add_parser("status", help="op-log status")
1504
1656
  p_status.add_argument("--json", action="store_true", dest="as_json")
1505
1657
  p_graph = sub.add_parser(
@@ -1555,6 +1707,17 @@ def build_parser() -> argparse.ArgumentParser:
1555
1707
  p_ll = lsub.add_parser("list", help="list active leases")
1556
1708
  p_ll.add_argument("--json", action="store_true", dest="as_json")
1557
1709
  lsub.add_parser("sweep", help="sweep expired leases")
1710
+ p_lc = lsub.add_parser(
1711
+ "contact",
1712
+ help="ask whoever holds a path's lease to release it -- the missing "
1713
+ "half of 'talk to them or wait'")
1714
+ p_lc.add_argument("path")
1715
+ p_lc.add_argument("-m", "--message", default="", help="why you need it")
1716
+ p_lc.add_argument("--actor", default=None)
1717
+ p_lq = lsub.add_parser(
1718
+ "requests",
1719
+ help="who is blocked on YOUR leases, and what you are waiting on")
1720
+ p_lq.add_argument("--actor", default=None)
1558
1721
 
1559
1722
  p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
1560
1723
  p_lc.add_argument("--actor", default=None)
@@ -1895,8 +2058,6 @@ def main(argv: Optional[List[str]] = None) -> int:
1895
2058
  return _cmd_capture(args)
1896
2059
  if args.cmd == "diff":
1897
2060
  return _cmd_diff(args)
1898
- if args.cmd == "data":
1899
- return _cmd_data(args)
1900
2061
  if args.cmd == "status":
1901
2062
  return _cmd_status(args)
1902
2063
  if args.cmd == "merge-preview":
@@ -83,6 +83,16 @@ def gather(data_root=None, since: Optional[str] = None) -> dict:
83
83
  ambiguous = [c for c in collisions if _agent_sessions(c["actors"]) < 2]
84
84
  total = len(ops)
85
85
  multi_actor = len(actors) > 1
86
+
87
+ # Optional host enrichment: the op-log says what changed and who changed it,
88
+ # never why. A host holding the agents' reasoning traces can answer that.
89
+ # Absent is the normal state and yields no key at all — an explicit zero
90
+ # would claim these agents worked without reasoning, which is a different
91
+ # and false statement.
92
+ from awgit import plugins as _plugins
93
+
94
+ reasoning = _plugins.thoughts(sorted(actors))
95
+
86
96
  return {
87
97
  "ops": total,
88
98
  "node_changes": nodes,
@@ -101,6 +111,8 @@ def gather(data_root=None, since: Optional[str] = None) -> dict:
101
111
  # The honesty flag. Every collision number below is meaningless without
102
112
  # it, and a reader who does not know that will over-read a zero.
103
113
  "can_detect_collisions": multi_actor,
114
+ # Present only when a host registered the THOUGHTS hook.
115
+ **({"reasoning": reasoning} if reasoning else {}),
104
116
  }
105
117
 
106
118
 
@@ -123,6 +135,14 @@ def render(ev: dict) -> str:
123
135
  f"{ev.get('confirmed_multi_agent_collisions', 0)} CONFIRMED "
124
136
  f"(two distinct agent sessions), "
125
137
  f"{ev.get('ambiguous_collisions', 0)} ambiguous")
138
+ reasoning = ev.get("reasoning")
139
+ if reasoning:
140
+ traces = reasoning.get("traces")
141
+ linked = reasoning.get("linked_actors")
142
+ detail = f"{traces} trace(s)" if traces is not None else "available"
143
+ if linked is not None:
144
+ detail += f" across {linked} actor(s)"
145
+ out.append(f" reasoning captured {detail}")
126
146
  if not ev["can_detect_collisions"]:
127
147
  out.append("")
128
148
  out.append(" NOTE: only ONE actor appears in this op-log, so a collision")