@ssheleg/agent-sync 1.3.1 → 1.3.4

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
@@ -3,6 +3,52 @@
3
3
  All notable changes to this project are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.3.4 — 2026-07-29
7
+
8
+ ### Fixed
9
+ - **`release` reported success for a lease it did not release, and cleared the
10
+ board claim on the way.** The lease plane refused correctly — a lease held by
11
+ another run stayed held, and `_git_release` printed a note saying so — but the
12
+ command printed `released <key>` over the top of it and exited 0, and
13
+ `write_claim` had already blanked the claim cell before the refusal was
14
+ reached. The result was the board advertising a task as free while the lease
15
+ still held it: the exact disagreement a lease exists to prevent, manufactured
16
+ by the tool. Ownership is now checked **first**, in whichever plane arbitrates
17
+ it; nothing is written when the answer is no; the command exits non-zero and
18
+ says who holds it. Both lease modes were affected.
19
+ - A regression check covers it in `local` and `git` mode, and was probed against
20
+ the old code in both before being trusted.
21
+
22
+ ## 1.3.3 — 2026-07-29
23
+
24
+ ### Fixed
25
+ - **The git lease backend could not take a lease on any machine without a git
26
+ identity.** Acquiring writes a lease object with `git commit-tree`, which
27
+ refuses when `user.email` is unset and cannot be auto-detected — CI runners,
28
+ containers, a freshly provisioned box. So the backend that advertises
29
+ *exclusive across machines* failed on precisely the machines least likely to
30
+ have a personal git config, with `could not create the lease object` and no
31
+ further detail. The lease object is plumbing, not authorship: it now carries a
32
+ fixed `agent-sync <agent-sync@localhost>` identity passed inline, so it never
33
+ depends on ambient config.
34
+ - The same failure now reports git's own last line instead of swallowing it. The
35
+ bug survived six red CI runs because the message named a possible cause and
36
+ showed no evidence.
37
+
38
+ ## 1.3.2 — 2026-07-29
39
+
40
+ Open-source hygiene — the repo is public and ships in the `sshlg-skills` bundle,
41
+ so the files a first-time contributor looks for now exist.
42
+
43
+ ### Added
44
+ - `CODE_OF_CONDUCT.md`, issue forms and a pull-request template. The forms ask
45
+ the question that actually matters for this project: **how many agents were
46
+ running, against what checkout** — a coordination bug reported without the
47
+ concurrency shape is not reproducible.
48
+ - The PR checklist requires a negative self-test with any new validator guard —
49
+ plant the defect, watch the check fail, then trust the green.
50
+ - README points at the code of conduct and at the family bundle.
51
+
6
52
  ## 1.3.1 — 2026-07-29
7
53
 
8
54
  ### The git lease was invisible to everything that reads a lease — fixed
@@ -24,6 +70,22 @@ already removed that note, which is why only one half of the loop was ever writt
24
70
  now runs acquire → `whoami` → `guard` → release against **both** modes; against 1.3.0 it fails with
25
71
  the two symptoms above, which is the point of adding it.
26
72
 
73
+ ## 1.3.1 — 2026-07-29
74
+
75
+ ### Ignoring the state directory does nothing once git is tracking it
76
+
77
+ Found in the project this plugin was built for: `.agent-sync/` was gitignored **and committed**,
78
+ because the files went in before the rule existed. Consequences, all of them silent:
79
+
80
+ - every tool call rewrites `last-renew`, so all three repositories were permanently dirty and no
81
+ run could ever report itself finished
82
+ - `run-id` is the checkout's **agent identity**. Committed, it reaches every clone — two machines
83
+ would then coordinate as one run, which is the failure 1.3.0 fixed at the other end
84
+
85
+ `init` now untracks the directory when it finds it tracked, and `check` reports it as a problem
86
+ rather than passing a project whose state is versioned. Probed: a repository with a committed
87
+ `.agent-sync/run-id` fails `check` with the exact removal command, and passes once it is untracked.
88
+
27
89
  ## 1.3.0 — 2026-07-29
28
90
 
29
91
  ### Two agents in one checkout were one identity — fixed
package/README.md CHANGED
@@ -389,8 +389,13 @@ agent loads on their own trigger rather than by default:
389
389
  | [`two-sources.md`](plugins/agent-sync/skills/agent-sync/references/two-sources.md) | before the first reconcile, or when deciding where a document belongs |
390
390
  | [`roadmap.md`](plugins/agent-sync/skills/agent-sync/references/roadmap.md) | configuring `claimTags`, taking or closing a task, or re-planning a board |
391
391
 
392
- See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Security
393
- reports: [SECURITY.md](SECURITY.md).
392
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Everyone
393
+ taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
394
+ Security reports: [SECURITY.md](SECURITY.md).
395
+
396
+ `agent-sync` also ships in the [sshlg-skills](https://github.com/ssheleg/sshlg-skills)
397
+ bundle, which installs the whole family for Claude Code, Cursor, Codex and 70+
398
+ other agents with one command.
394
399
 
395
400
  ## License
396
401
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.3.1",
3
+ "version": "1.3.4",
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
- "version": "1.3.1",
4
- "description": "Coordination layer for multi-agent repositories leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
3
+ "version": "1.3.4",
4
+ "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.",
5
5
  "author": {
6
6
  "name": "appvillis-com"
7
7
  },
@@ -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.3.1"
7
+ version: "1.3.4"
8
8
  author: appvillis-com
9
9
  ---
10
10
 
@@ -32,7 +32,7 @@ from datetime import datetime, timezone
32
32
  from pathlib import Path
33
33
  from typing import Any
34
34
 
35
- VERSION = "1.3.1"
35
+ VERSION = "1.3.4"
36
36
 
37
37
  CONFIG_PATH = Path(".claude/agent-sync.json")
38
38
  ENV_FILE = Path(".env.agent-sync")
@@ -813,10 +813,20 @@ class Sync:
813
813
  payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
814
814
  "repo": repo_name(), "host": os.uname().nodename})
815
815
  empty_tree = git("hash-object", "-t", "tree", "/dev/null")
816
- commit = subprocess.run(["git", "commit-tree", empty_tree], input=payload,
817
- capture_output=True, text=True).stdout.strip()
816
+ # A lease object is plumbing, not authorship, so it must not depend on the
817
+ # machine having a git identity. Without these `-c` flags `commit-tree`
818
+ # refuses wherever user.email is unset and cannot be auto-detected — CI
819
+ # runners, containers, a freshly provisioned box — and the lease backend
820
+ # silently becomes unusable on exactly the machines that need it most.
821
+ made = subprocess.run(
822
+ ["git", "-c", "user.name=agent-sync", "-c", "user.email=agent-sync@localhost",
823
+ "commit-tree", empty_tree],
824
+ input=payload, capture_output=True, text=True)
825
+ commit = made.stdout.strip()
818
826
  if not commit:
819
- raise Fail("could not create the lease object — is this a git repository?")
827
+ detail = (made.stderr or "").strip().splitlines()
828
+ why = detail[-1] if detail else "no output from git commit-tree"
829
+ raise Fail(f"could not create the lease object — is this a git repository? ({why})")
820
830
 
821
831
  args = ["git", "push", remote, f"{commit}:{ref}"]
822
832
  if held_sha: # stealing an expired lease, and only that
@@ -950,7 +960,36 @@ class Sync:
950
960
  marker.parent.mkdir(parents=True, exist_ok=True)
951
961
  marker.write_text(now_iso())
952
962
 
953
- def release(self, key: str) -> None:
963
+ def _lease_holder(self, key: str) -> str | None:
964
+ """Who holds this lease right now, in whichever plane arbitrates it."""
965
+ if self.lease_mode == "git":
966
+ sha, held = self._git_read_lease(key)
967
+ return held.get("run") if sha else None
968
+ lock = self._local_lock(key)
969
+ if not lock.exists():
970
+ return None
971
+ try:
972
+ return json.loads(lock.read_text()).get("run")
973
+ except (json.JSONDecodeError, OSError):
974
+ return None
975
+
976
+ def release(self, key: str) -> bool:
977
+ """Release only what this run holds, and say so plainly when it does not.
978
+
979
+ This used to clear the board claim and report success unconditionally. The lease
980
+ plane refused correctly — `_git_release` prints a note and returns — but the
981
+ caller printed "released" over the top of it and exited 0, and `write_claim` had
982
+ already blanked the claim cell on the way in. The board then said the task was
983
+ free while the lease said it was taken: the exact disagreement a lease exists to
984
+ prevent, manufactured by the tool. Ownership is therefore checked FIRST, and
985
+ nothing is written when the answer is no.
986
+ """
987
+ holder = self._lease_holder(key)
988
+ if holder is not None and holder != self.rid:
989
+ print(f"note: {key} is held by {holder}, not this run — nothing released",
990
+ file=sys.stderr)
991
+ return False
992
+
954
993
  for n in self.write_claim(key, None):
955
994
  print(f" {n}")
956
995
  if self.lease_mode == "git":
@@ -968,6 +1007,7 @@ class Sync:
968
1007
  fmt_line("release", key, self.rid))
969
1008
  except Fail as exc:
970
1009
  print(f"note: released locally, not published ({exc})", file=sys.stderr)
1010
+ return True
971
1011
 
972
1012
  def held(self) -> list[str]:
973
1013
  d = self.root / STATE_DIR / "leases"
@@ -1778,6 +1818,7 @@ def cmd_init(args: argparse.Namespace) -> int:
1778
1818
 
1779
1819
  ensure_gitignored(root, str(ENV_FILE))
1780
1820
  ensure_gitignored(root, f"{STATE_DIR}/")
1821
+ ensure_untracked(root, f"{STATE_DIR}/")
1781
1822
 
1782
1823
  print()
1783
1824
  if backend == "outline":
@@ -1833,6 +1874,25 @@ def ensure_gitignored(root: Path, entry: str) -> None:
1833
1874
  print(f"✓ added {entry} to .gitignore")
1834
1875
 
1835
1876
 
1877
+ def ensure_untracked(root: Path, entry: str) -> None:
1878
+ """Ignoring a path does nothing once git is already tracking it.
1879
+
1880
+ Found in a real project: the state directory had been committed before the ignore rule
1881
+ existed, so `git status` reported it modified after **every** tool call — the repository was
1882
+ permanently dirty and no run could report itself finished. `run-id` is the worse half: it is
1883
+ this checkout's agent identity, and committed it reaches every clone, so two machines
1884
+ coordinate as one run.
1885
+ """
1886
+ tracked = git("ls-files", "--", entry.rstrip("/"), cwd=root)
1887
+ if not tracked:
1888
+ return
1889
+ if git("rm", "-r", "--cached", "-q", "--", entry.rstrip("/"), cwd=root) is None:
1890
+ return
1891
+ n = len(tracked.split("\n"))
1892
+ print(f"✓ untracked {n} committed file(s) under {entry} — commit that removal; "
1893
+ "ignoring a tracked path has no effect")
1894
+
1895
+
1836
1896
  # --------------------------------------------------------------------------- status
1837
1897
 
1838
1898
  def cmd_status(_args: argparse.Namespace) -> int:
@@ -1978,7 +2038,11 @@ def cmd_renew(args: argparse.Namespace) -> int:
1978
2038
 
1979
2039
 
1980
2040
  def cmd_release(args: argparse.Namespace) -> int:
1981
- Sync().release(args.key)
2041
+ # Exit non-zero when nothing was released. A caller that scripts `release` in a
2042
+ # cleanup path has no other way to learn the lease is still out there.
2043
+ if not Sync().release(args.key):
2044
+ print(f"NOT released: {args.key} is held by another run", file=sys.stderr)
2045
+ return 1
1982
2046
  print(f"released {args.key}")
1983
2047
  return 0
1984
2048
 
@@ -2714,6 +2778,16 @@ def cmd_check(_args: argparse.Namespace) -> int:
2714
2778
  except Fail:
2715
2779
  pass
2716
2780
 
2781
+ tracked_state = git("ls-files", "--", STATE_DIR)
2782
+ if tracked_state:
2783
+ problems.append(
2784
+ f"{STATE_DIR}/ is tracked by git ({len(tracked_state.split(chr(10)))} file(s)) — it is "
2785
+ "generated state, the repository is dirty after every tool call, and a committed "
2786
+ "run-id hands this checkout's identity to every clone. "
2787
+ f"Run: git rm -r --cached {STATE_DIR} && commit")
2788
+ else:
2789
+ ok.append(f"{STATE_DIR}/ is not tracked")
2790
+
2717
2791
  for line in ok:
2718
2792
  print(f" ✓ {line}")
2719
2793
  for line in warn: