@ssheleg/agent-sync 1.7.1 → 1.8.0

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## v1.8.0
2
+
3
+ **The board, cleared.** Nine rows opened by the two audits of 2026-08-10, closed with a check, a
4
+ measurement or a written decision — because a backlog nobody empties is a list of things everyone
5
+ has agreed to stop seeing.
6
+
7
+ ### The two scenarios that were only ever driven by hand are now gated
8
+
9
+ Two agents contending for one task, and the guard across every shape a write arrives in, were
10
+ verified by executing them and had no check that fails on its own — the exact state in which the
11
+ first audit found six shipped defects. `check_two_agents_cannot_share_one_task` drives the full
12
+ sequence from two identities: the second run loses, is told who holds it, sees the holding in
13
+ `status`, is denied the guarded registry, cannot release a lease it does not hold, and the holder
14
+ can. `check_guard_covers_every_write_shape` drives nine payloads through the real hook — `Edit`,
15
+ `Write`, `NotebookEdit`, an unguarded file, `git commit`, `git -C <dir> commit`,
16
+ `cd <dir> && git commit`, `git log --grep=commit`, and malformed input — with and without the lease.
17
+
18
+ Planting the first defect took two mutations, not one, and that is worth recording: breaking only
19
+ `acquire`'s expiry check still refuses the steal, because `_steal_expired` re-reads the expiry
20
+ **inside** the critical section. The exclusion has two independent layers. The single-point fixture
21
+ was MISSED, which is how that was discovered.
22
+
23
+ ### `check_merge_releases_only_its_key`
24
+
25
+ `merge --key` must release that lease and leave the others held. It was verified by reading the
26
+ code, and every defect the first audit found was in something verified by reading.
27
+
28
+ ### `status` on a large repository: 3.2 s → 0.5 s
29
+
30
+ `check` resolved guarded and claim-tag patterns by walking the whole tree **once per pattern** —
31
+ five patterns over a 20 000-file repository is five full walks, measured at 3.2 s for a single
32
+ `status`, which is a `SessionStart` hook. It now walks once, and uses git's own index where there is
33
+ one, so `.gitignore` is honoured for free. Same output, six times faster.
34
+
35
+ ### `check_commands_work_without_the_family_installed`
36
+
37
+ Runs the commands with `HOME` redirected at an empty directory — the state of every CI runner, and
38
+ where the task-pipeline ordering defect was obvious while this development machine could not see it.
39
+ The check proves its own isolation on a healthy project before asserting on a broken one, because a
40
+ probe that cannot fail is not a probe.
41
+
42
+ ### The self-test: 6 min → 2.8 min
43
+
44
+ Each fixture now runs as its own process, eight at a time. It was a loop that reassigned module
45
+ globals and called `main()` in-band, once per fixture; at 32 fixtures it had already blown a
46
+ ten-minute command budget, and a suite people stop running is a suite that does not exist. The
47
+ subprocess also removes the global-state reset that made parallelism impossible.
48
+
49
+ ### Decisions recorded rather than deferred again
50
+
51
+ **`settleSeconds` stays.** It is the adapter contract's extension point for a store whose writes are
52
+ not immediately readable; removing it would fail every config that carries one to buy nothing.
53
+ Written into `references/adapter-contract.md`, where the next reader will look.
54
+
55
+ **Blockers stay gone.** The concept needs a writer, a reader and a place on the board before the
56
+ word earns a document.
57
+
58
+ **Guard latency is fine:** 130–220 ms per guarded edit with no run id in the environment, against a
59
+ `PreToolUse` budget of 20 s. Measured, recorded, no change.
60
+
61
+ ### Also
62
+
63
+ `actions/checkout@v5`, `actions/setup-node@v5`, `actions/setup-python@v6` — the v4 pins were being
64
+ forced onto Node 24 by the runner and annotated every release.
65
+
66
+ `merge` also gained a preflight it was missing. A merge commit is authorship, so it needs a real
67
+ git identity — unlike a lease object, which is plumbing and is written with a synthetic one on
68
+ purpose. Without one the merge started, git refused at the commit, and the abort path ran: it
69
+ recovers, and it is still not what a command whose whole doctrine is *every check before anything
70
+ is touched* promises. Found by CI, because a runner has no global `.gitconfig` and this machine
71
+ does — the same blind spot, twice in one day.
72
+
73
+ The validator now runs 44 checks and plants and catches 37 distinct defects.
74
+
1
75
  ## v1.7.1
2
76
 
3
77
  **A second audit, along a different axis: not whether the tool keeps its promises, but whether an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding \u2014 leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.7.1",
4
+ "version": "1.8.0",
5
5
  "description": "Coordination layer for multi-agent repositories \u2014 leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.7.1"
7
+ version: "1.8.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -1,3 +1,11 @@
1
+ > **`settleSeconds` is this contract's extension point, and nothing shipped reads it.**
2
+ > It exists for an adapter whose writes are not immediately visible to a subsequent read —
3
+ > a store with an asynchronous index, where a reader must wait before replaying. Neither
4
+ > `outline` nor `fs` needs it, so `check` says plainly that setting it does nothing here.
5
+ > It is kept rather than removed because deleting it would make every config carrying one
6
+ > fail validation to buy nothing, and because an adapter that needs it has nowhere else to
7
+ > ask. If you add a backend that waits, this is the knob; if you do not, ignore it.
8
+
1
9
  # Adapter contract
2
10
 
3
11
  **Read this when** adding a knowledge backend, auditing one, or deciding whether a
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.7.1"
36
+ VERSION = "1.8.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -231,18 +231,41 @@ def matches_glob(rel: str, pattern: str) -> bool:
231
231
  return bool(rx.match(rel.replace(os.sep, "/")))
232
232
 
233
233
 
234
- def glob_files(root: Path, pattern: str) -> list[Path]:
235
- """Every existing file the pattern covers, by the same rule the guard applies."""
234
+ def _tracked_files(root: Path) -> list[str]:
235
+ """Every candidate path in the repository, walked ONCE.
236
+
237
+ `glob_files` used to walk the whole tree per pattern, and `check` calls it for every
238
+ guarded pattern and every claim-tag pattern — five patterns over a 20 000-file
239
+ repository is five full walks. Measured at ~3 s for a single `status`, which since
240
+ 1.7.0 runs `check`, and `status` is a `SessionStart` hook. Git's own index is used
241
+ where it exists, because it already excludes everything `.gitignore` does; the walk
242
+ is the fallback for files not yet tracked.
243
+ """
244
+ listed = git("ls-files", "--cached", "--others", "--exclude-standard", cwd=root)
245
+ if listed:
246
+ return listed.split("\n")
236
247
  skip = {".git", "node_modules", STATE_DIR.name}
237
- out: list[Path] = []
248
+ out: list[str] = []
238
249
  for path in root.rglob("*"):
239
- if skip & set(path.relative_to(root).parts):
250
+ rel = path.relative_to(root)
251
+ if skip & set(rel.parts):
240
252
  continue
241
- if path.is_file() and matches_glob(str(path.relative_to(root)), pattern):
242
- out.append(path)
253
+ if path.is_file():
254
+ out.append(str(rel))
243
255
  return out
244
256
 
245
257
 
258
+ def glob_files(root: Path, pattern: str) -> list[Path]:
259
+ """Every existing file the pattern covers, by the same rule the guard applies."""
260
+ return [root / rel for rel in _tracked_files(root) if matches_glob(rel, pattern)]
261
+
262
+
263
+ def glob_files_many(root: Path, patterns: list[str]) -> dict[str, list[Path]]:
264
+ """The same answer for several patterns, from one walk."""
265
+ files = _tracked_files(root)
266
+ return {p: [root / rel for rel in files if matches_glob(rel, p)] for p in patterns}
267
+
268
+
246
269
  # --------------------------------------------------------------------------- config
247
270
 
248
271
  def find_env_file(root: Path) -> Path | None:
@@ -3155,9 +3178,11 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3155
3178
  ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
3156
3179
 
3157
3180
  # A guard glob that matches nothing protects nothing. Resolved by the same function
3158
- # the guard itself applies, so the two commands cannot disagree about one pattern.
3181
+ # the guard itself applies, so the two commands cannot disagree about one pattern
3182
+ # and from one walk of the repository, not one per pattern.
3183
+ guard_hits = glob_files_many(root, list(cfg.get("guardedFiles") or []))
3159
3184
  for pattern in (cfg.get("guardedFiles") or []):
3160
- hits = glob_files(root, pattern)
3185
+ hits = guard_hits.get(pattern) or []
3161
3186
  if not hits:
3162
3187
  problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
3163
3188
  if cfg.get("guardedFiles"):
@@ -3165,8 +3190,9 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3165
3190
  else:
3166
3191
  warn.append("no guarded files — nothing requires a lease in this repository")
3167
3192
 
3193
+ claim_hits = glob_files_many(root, list(cfg.get("claimTags") or {}))
3168
3194
  for pattern, spec in (cfg.get("claimTags") or {}).items():
3169
- files = glob_files(root, pattern)
3195
+ files = claim_hits.get(pattern) or []
3170
3196
  if not files:
3171
3197
  problems.append(f"claimTags pattern '{pattern}' matches no file")
3172
3198
  continue
@@ -3397,6 +3423,21 @@ def cmd_merge(args: argparse.Namespace) -> int:
3397
3423
  raise Fail(f"working tree is not clean ({len(dirty)} path(s)) — commit or stash first; "
3398
3424
  "a merge cannot tell your uncommitted work from the branch's")
3399
3425
 
3426
+ # A merge commit is authorship, so it needs a real identity — unlike a lease object,
3427
+ # which is plumbing and is written with a synthetic one on purpose. Checked HERE
3428
+ # because everything in this command is checked before anything is touched: without
3429
+ # it the merge starts, git refuses at the commit, and the abort path runs. That is
3430
+ # recoverable and it is still not what this command promises. It is also the ordinary
3431
+ # state of a CI runner and a fresh container, which is where it was found.
3432
+ ident = subprocess.run(["git", "var", "GIT_COMMITTER_IDENT"],
3433
+ capture_output=True, text=True)
3434
+ if ident.returncode != 0:
3435
+ why = (ident.stderr or "").strip().splitlines()
3436
+ raise Fail("git has no committer identity here, so the merge commit cannot be "
3437
+ "written. Set one and run this again — nothing was touched:\n"
3438
+ " git config user.name '<you>' && git config user.email '<you@example>'"
3439
+ + (f"\n ({why[-1]})" if why else ""))
3440
+
3400
3441
  git("fetch", "--quiet", "origin", target)
3401
3442
  upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
3402
3443