@ssheleg/agent-sync 1.10.0 → 1.11.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,60 @@
1
+ ## v1.11.0 — releasing stops rewinding a cell, and a register with no pattern says so
2
+
3
+ Two more defects in the same afternoon that produced v1.10.1, both from using this tool
4
+ on a board that had just been worked through.
5
+
6
+ ### Fixed
7
+
8
+ - **Releasing no longer rewinds a cell that moved.** The restore was verbatim, and in this
9
+ family the claim cell IS the status cell — so `close then release` silently reopened a
10
+ row closed with evidence minutes earlier. Caught by `finish` reporting the board
11
+ uncommitted, not by anybody reading it. The protocol's intent is to remove *this run's*
12
+ marker, not to rewind the cell, so a changed cell keeps its change, loses only the
13
+ marker, and the note says so. An untouched cell is still restored exactly.
14
+ - **An id register with no pattern reports instead of crashing.** The script read
15
+ `nextFreeIdPattern`; every config this family ships writes `pattern`. Both are accepted
16
+ now — but the defect was the fallback: absence became `re.search("", text)`, which
17
+ matches the empty string at position 0, so `check` took the found branch and died with
18
+ `IndexError: no such group` rather than saying the register has no pattern. A component
19
+ that never received its input approved and then fell over.
20
+
21
+ ### Added
22
+
23
+ - **`test/claim_cell_test.py`** — 6 cases driving the shipped script as a process against
24
+ real project directories, because these defects are about what the command does to a
25
+ file on disk. **Four were watched failing** against the pre-fix script, including that
26
+ exact `IndexError`. One of the six is a regression test for v1.10.1's own fix: a board
27
+ where an id is cited by two other rows must still tag the row that owns it.
28
+
29
+ ## v1.10.1
30
+
31
+ The claim tag could not be placed on nearly half a real board, and the lease was granted
32
+ anyway.
33
+
34
+ ### Fixed
35
+
36
+ - **A row that CITES an id no longer defeats that id's claim.** `claimTags` in `cell` mode
37
+ selected every row *containing* the id, so an ordinary board sentence — "closed by
38
+ B-12", "blocked until T-1 ships" — made the id ambiguous and the tag was refused.
39
+ Cross-referencing is what boards do: measured on `sshlg-skills`' own board on
40
+ 2026-08-14, **14 of 41 rows cited others, leaving 19 ids that could never be tagged**.
41
+
42
+ The refusal itself was correct — guessing which row carries a claim is how a claim lands
43
+ on somebody else's work. What was wrong is that `acquire` still returned `won`: the
44
+ lease was granted while the registry silently carried no claim, which is precisely the
45
+ state a claim exists to make visible.
46
+
47
+ A markdown board declares which row is which in its **first cell**. `acquire` now
48
+ narrows to the row whose id cell equals the key, and refuses only when no row's id cell
49
+ matches. Tried *after* the existing marker narrowing, so `release` still trusts the
50
+ marker it actually wrote rather than re-deriving the row.
51
+
52
+ Covered by `check_claim_lands_on_the_row_whose_id_cell_matches()`, which builds a two-row
53
+ board where the second row cites the first, and asserts the tag lands on exactly one row
54
+ and that it is the right one — then that release still restores the file byte for byte.
55
+ Watched failing before the fix: *"acquire wrote no claim — a row citing T-1 defeated the
56
+ selector, and the lease was granted with the registry unmarked"*.
57
+
1
58
  ## v1.10.0
2
59
 
3
60
  Following `task-pipeline` v1.53.0, which renamed the artifact root's default from
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.10.0",
4
- "description": "Let concurrent coding agents share one project without colliding leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
3
+ "version": "1.11.0",
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"
7
7
  },
@@ -15,7 +15,7 @@
15
15
  "LICENSE"
16
16
  ],
17
17
  "scripts": {
18
- "test": "python3 test/validate.py && python3 test/validate.py --self-test",
18
+ "test": "python3 test/validate.py && python3 test/validate.py --self-test && python3 test/claim_cell_test.py",
19
19
  "prepublishOnly": "python3 test/validate.py"
20
20
  },
21
21
  "publishConfig": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.10.0",
4
+ "version": "1.11.0",
5
5
  "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.",
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.10.0"
7
+ version: "1.11.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -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.10.0"
36
+ VERSION = "1.11.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -201,6 +201,24 @@ class Fail(Exception):
201
201
  _GLOB_CACHE: dict[str, re.Pattern[str]] = {}
202
202
 
203
203
 
204
+
205
+ def id_pattern(spec: dict) -> str | None:
206
+ """The regex that finds a register's next free id, under either accepted key.
207
+
208
+ The script read `nextFreeIdPattern`; every config this family ships writes `pattern`.
209
+ Both are accepted now, because a key mismatch is a documentation problem and should
210
+ not be a crash — but ABSENCE returns None rather than `""`, which is the whole defect:
211
+ `re.search("", text)` matches the empty string at position 0, so `check` took the found
212
+ branch and died with `IndexError: no such group` instead of saying the register has no
213
+ pattern. A component that never received its input approved and then fell over.
214
+ """
215
+ for k in ("nextFreeIdPattern", "pattern"):
216
+ v = spec.get(k)
217
+ if v:
218
+ return v
219
+ return None
220
+
221
+
204
222
  def matches_glob(rel: str, pattern: str) -> bool:
205
223
  """Repo-root-anchored glob — ONE implementation, for the guard and for `check`.
206
224
 
@@ -1372,7 +1390,12 @@ class Sync:
1372
1390
  path = self.root / spec["file"]
1373
1391
  if not path.exists():
1374
1392
  raise Fail(f"register file {spec['file']} does not exist")
1375
- m = re.search(spec["nextFreeIdPattern"], path.read_text())
1393
+ pat = id_pattern(spec)
1394
+ if not pat:
1395
+ raise Fail(f"register has no id pattern — set `pattern` (or the legacy "
1396
+ f"`nextFreeIdPattern`) for {spec['file']}; without one there is "
1397
+ f"nothing to read a next free id out of")
1398
+ m = re.search(pat, path.read_text())
1376
1399
  if not m:
1377
1400
  raise Fail(f"could not read the next free id out of {spec['file']}")
1378
1401
  return int(m.group(1))
@@ -1668,6 +1691,22 @@ class Sync:
1668
1691
  if len(narrowed) == 1:
1669
1692
  hits = narrowed
1670
1693
 
1694
+ # A board says which row is which in its FIRST cell. Every other match is a
1695
+ # row CITING this id — "closed by B-12", "blocked until T-1 ships" — and boards
1696
+ # cross-reference constantly: on the family's own board 14 rows cited others, so
1697
+ # 19 of 41 ids had two candidate rows and their claim was refused. The refusal
1698
+ # was right; granting the lease anyway was not, because the registry then carries
1699
+ # no claim while `acquire` reports `won`. Tried after the marker so a release
1700
+ # still trusts what it actually wrote.
1701
+ if len(hits) > 1:
1702
+ own = []
1703
+ for i in hits:
1704
+ cells = self._row_cells(lines[i])
1705
+ if cells and cells[0].strip() == key:
1706
+ own.append(i)
1707
+ if len(own) == 1:
1708
+ hits = own
1709
+
1671
1710
  if len(hits) > 1:
1672
1711
  notes.append(f"{rel}: `{key}` appears in {len(hits)} table rows — refusing "
1673
1712
  "to guess which one is the claim. Narrow the pattern or edit by hand")
@@ -1704,7 +1743,23 @@ class Sync:
1704
1743
  else:
1705
1744
  if saved is None:
1706
1745
  continue # nothing of ours to undo
1707
- cells[idx] = saved
1746
+ # Restoring VERBATIM loses any edit made while the claim was held, and in
1747
+ # this family the claim cell IS the status cell — so `close then release`
1748
+ # silently reopened a row closed with evidence minutes earlier (B-35,
1749
+ # 2026-08-14, caught by `finish` reporting the board uncommitted, not by
1750
+ # anybody reading it). The protocol's intent is to remove this run's
1751
+ # marker, not to rewind the cell, so when the text moved on, strip the
1752
+ # marker and keep the movement.
1753
+ template = spec.get("held") or "{prev} (claimed: {holder})"
1754
+ marker = template.replace("{prev}", "").replace("{holder}", self.rid).strip()
1755
+ expected = template.replace("{prev}", saved.strip()).replace("{holder}", self.rid)
1756
+ if current.strip() == expected.strip() or not marker:
1757
+ cells[idx] = saved
1758
+ else:
1759
+ stripped = current.replace(marker, "").rstrip()
1760
+ cells[idx] = (stripped if stripped.strip() else saved)
1761
+ notes.append(f"{rel}: `{key}`'s cell changed while the claim was held — "
1762
+ f"kept the change and removed only the claim marker")
1708
1763
  state.get(key, {}).pop(str(rel), None)
1709
1764
  if not state.get(key):
1710
1765
  state.pop(key, None)
@@ -3166,10 +3221,17 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3166
3221
  problems.append(f"register {reg}: file '{spec.get('file')}' does not exist")
3167
3222
  continue
3168
3223
  text = f.read_text()
3224
+ pat = id_pattern(spec)
3225
+ if not pat:
3226
+ problems.append(f"register {reg}: no id pattern — set `pattern` (or the legacy "
3227
+ f"`nextFreeIdPattern`). An absent one used to become "
3228
+ f"`re.search(\"\", text)`, which matches at position 0, so this "
3229
+ f"check took the found branch and crashed instead of reporting")
3230
+ continue
3169
3231
  try:
3170
- m = re.search(spec.get("nextFreeIdPattern", ""), text)
3232
+ m = re.search(pat, text)
3171
3233
  except re.error as exc:
3172
- problems.append(f"register {reg}: nextFreeIdPattern is not valid regex ({exc})")
3234
+ problems.append(f"register {reg}: id pattern is not valid regex ({exc})")
3173
3235
  continue
3174
3236
  if not m:
3175
3237
  problems.append(f"register {reg}: nextFreeIdPattern matches nothing in "