@ssheleg/agent-sync 1.7.1 → 1.8.1
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 +84 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +1 -1
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +8 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-outline.md +11 -0
- package/plugins/agent-sync/skills/agent-sync/references/branching.md +9 -0
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/references/roadmap.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/references/two-sources.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +51 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
+
## v1.8.1
|
|
2
|
+
|
|
3
|
+
### Changed
|
|
4
|
+
|
|
5
|
+
- **Six references over 100 lines now open with a `## Contents` list.** The
|
|
6
|
+
canon asks for it because a partial read is what an agent actually does with
|
|
7
|
+
a long reference; without the list, a partial read returns an arbitrary
|
|
8
|
+
slice. Generated from each file's own `##` headings, so the list cannot
|
|
9
|
+
disagree with the document.
|
|
10
|
+
|
|
11
|
+
## v1.8.0
|
|
12
|
+
|
|
13
|
+
**The board, cleared.** Nine rows opened by the two audits of 2026-08-10, closed with a check, a
|
|
14
|
+
measurement or a written decision — because a backlog nobody empties is a list of things everyone
|
|
15
|
+
has agreed to stop seeing.
|
|
16
|
+
|
|
17
|
+
### The two scenarios that were only ever driven by hand are now gated
|
|
18
|
+
|
|
19
|
+
Two agents contending for one task, and the guard across every shape a write arrives in, were
|
|
20
|
+
verified by executing them and had no check that fails on its own — the exact state in which the
|
|
21
|
+
first audit found six shipped defects. `check_two_agents_cannot_share_one_task` drives the full
|
|
22
|
+
sequence from two identities: the second run loses, is told who holds it, sees the holding in
|
|
23
|
+
`status`, is denied the guarded registry, cannot release a lease it does not hold, and the holder
|
|
24
|
+
can. `check_guard_covers_every_write_shape` drives nine payloads through the real hook — `Edit`,
|
|
25
|
+
`Write`, `NotebookEdit`, an unguarded file, `git commit`, `git -C <dir> commit`,
|
|
26
|
+
`cd <dir> && git commit`, `git log --grep=commit`, and malformed input — with and without the lease.
|
|
27
|
+
|
|
28
|
+
Planting the first defect took two mutations, not one, and that is worth recording: breaking only
|
|
29
|
+
`acquire`'s expiry check still refuses the steal, because `_steal_expired` re-reads the expiry
|
|
30
|
+
**inside** the critical section. The exclusion has two independent layers. The single-point fixture
|
|
31
|
+
was MISSED, which is how that was discovered.
|
|
32
|
+
|
|
33
|
+
### `check_merge_releases_only_its_key`
|
|
34
|
+
|
|
35
|
+
`merge --key` must release that lease and leave the others held. It was verified by reading the
|
|
36
|
+
code, and every defect the first audit found was in something verified by reading.
|
|
37
|
+
|
|
38
|
+
### `status` on a large repository: 3.2 s → 0.5 s
|
|
39
|
+
|
|
40
|
+
`check` resolved guarded and claim-tag patterns by walking the whole tree **once per pattern** —
|
|
41
|
+
five patterns over a 20 000-file repository is five full walks, measured at 3.2 s for a single
|
|
42
|
+
`status`, which is a `SessionStart` hook. It now walks once, and uses git's own index where there is
|
|
43
|
+
one, so `.gitignore` is honoured for free. Same output, six times faster.
|
|
44
|
+
|
|
45
|
+
### `check_commands_work_without_the_family_installed`
|
|
46
|
+
|
|
47
|
+
Runs the commands with `HOME` redirected at an empty directory — the state of every CI runner, and
|
|
48
|
+
where the task-pipeline ordering defect was obvious while this development machine could not see it.
|
|
49
|
+
The check proves its own isolation on a healthy project before asserting on a broken one, because a
|
|
50
|
+
probe that cannot fail is not a probe.
|
|
51
|
+
|
|
52
|
+
### The self-test: 6 min → 2.8 min
|
|
53
|
+
|
|
54
|
+
Each fixture now runs as its own process, eight at a time. It was a loop that reassigned module
|
|
55
|
+
globals and called `main()` in-band, once per fixture; at 32 fixtures it had already blown a
|
|
56
|
+
ten-minute command budget, and a suite people stop running is a suite that does not exist. The
|
|
57
|
+
subprocess also removes the global-state reset that made parallelism impossible.
|
|
58
|
+
|
|
59
|
+
### Decisions recorded rather than deferred again
|
|
60
|
+
|
|
61
|
+
**`settleSeconds` stays.** It is the adapter contract's extension point for a store whose writes are
|
|
62
|
+
not immediately readable; removing it would fail every config that carries one to buy nothing.
|
|
63
|
+
Written into `references/adapter-contract.md`, where the next reader will look.
|
|
64
|
+
|
|
65
|
+
**Blockers stay gone.** The concept needs a writer, a reader and a place on the board before the
|
|
66
|
+
word earns a document.
|
|
67
|
+
|
|
68
|
+
**Guard latency is fine:** 130–220 ms per guarded edit with no run id in the environment, against a
|
|
69
|
+
`PreToolUse` budget of 20 s. Measured, recorded, no change.
|
|
70
|
+
|
|
71
|
+
### Also
|
|
72
|
+
|
|
73
|
+
`actions/checkout@v5`, `actions/setup-node@v5`, `actions/setup-python@v6` — the v4 pins were being
|
|
74
|
+
forced onto Node 24 by the runner and annotated every release.
|
|
75
|
+
|
|
76
|
+
`merge` also gained a preflight it was missing. A merge commit is authorship, so it needs a real
|
|
77
|
+
git identity — unlike a lease object, which is plumbing and is written with a synthetic one on
|
|
78
|
+
purpose. Without one the merge started, git refused at the commit, and the abort path ran: it
|
|
79
|
+
recovers, and it is still not what a command whose whole doctrine is *every check before anything
|
|
80
|
+
is touched* promises. Found by CI, because a runner has no global `.gitconfig` and this machine
|
|
81
|
+
does — the same blind spot, twice in one day.
|
|
82
|
+
|
|
83
|
+
The validator now runs 44 checks and plants and catches 37 distinct defects.
|
|
84
|
+
|
|
1
85
|
## v1.7.1
|
|
2
86
|
|
|
3
87
|
**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.
|
|
3
|
+
"version": "1.8.1",
|
|
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.
|
|
4
|
+
"version": "1.8.1",
|
|
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
|
+
version: "1.8.1"
|
|
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
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**Read this when** making any Outline API call or debugging one.
|
|
4
4
|
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Shape of the API](#shape-of-the-api)
|
|
8
|
+
- [Capabilities](#capabilities)
|
|
9
|
+
- [Primitive mapping](#primitive-mapping)
|
|
10
|
+
- [Calling it without leaking the token](#calling-it-without-leaking-the-token)
|
|
11
|
+
- [Rate limits](#rate-limits)
|
|
12
|
+
- [Getting a token](#getting-a-token)
|
|
13
|
+
- [Verifying an instance](#verifying-an-instance)
|
|
14
|
+
|
|
15
|
+
|
|
5
16
|
[Outline](https://www.getoutline.com) is a collaborative knowledge base, available
|
|
6
17
|
hosted or self-hosted. Both work; the instance URL is configuration, never code.
|
|
7
18
|
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
**Read this when** starting work that will produce commits, merging a branch, or deciding
|
|
4
4
|
where a claim should be written.
|
|
5
5
|
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [The rule](#the-rule)
|
|
9
|
+
- [Where a claim lives](#where-a-claim-lives)
|
|
10
|
+
- [Landing the work](#landing-the-work)
|
|
11
|
+
- [The merge log](#the-merge-log)
|
|
12
|
+
- [What this does not do](#what-this-does-not-do)
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
## The rule
|
|
7
16
|
|
|
8
17
|
> **Work happens on a branch. The integration branch is somebody else's stable base.**
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
**Read this when** changing acquisition, expiry, stealing, or id allocation — or
|
|
4
4
|
when two agents disagree about who holds something.
|
|
5
5
|
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Line grammar](#line-grammar)
|
|
9
|
+
- [Acquiring — the third design, and the first that is true](#acquiring--the-third-design-and-the-first-that-is-true)
|
|
10
|
+
- [Expiry and stealing](#expiry-and-stealing)
|
|
11
|
+
- [Releasing](#releasing)
|
|
12
|
+
- [Id reservation](#id-reservation)
|
|
13
|
+
- [The lease is not the claim](#the-lease-is-not-the-claim)
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
Two different mechanisms, and confusing them is how this went wrong twice:
|
|
7
17
|
|
|
8
18
|
> **A lease is decided by an atomic operation** — `O_EXCL` on one filesystem, or a pushed
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
**Read this when** wiring `pipeline.json`, or adding a stage hook.
|
|
4
4
|
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [The numbers, once](#the-numbers-once)
|
|
8
|
+
- [Where it plugs in](#where-it-plugs-in)
|
|
9
|
+
- [pipeline.json](#pipelinejson)
|
|
10
|
+
- [What must be guarded](#what-must-be-guarded)
|
|
11
|
+
- [Preflight](#preflight)
|
|
12
|
+
- [Gate expressions](#gate-expressions)
|
|
13
|
+
|
|
14
|
+
|
|
5
15
|
`agent-sync` supplies stages; it does not define them. The stage names below are
|
|
6
16
|
`task-pipeline`'s own — do not rename, renumber or fork them.
|
|
7
17
|
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
**Read this when** configuring `claimTags`, taking or releasing a task, closing one, or
|
|
4
4
|
re-planning work that is already on a board.
|
|
5
5
|
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [The two records, and why both exist](#the-two-records-and-why-both-exist)
|
|
9
|
+
- [How the write is made safe](#how-the-write-is-made-safe)
|
|
10
|
+
- [Configuring it](#configuring-it)
|
|
11
|
+
- [Closing a task](#closing-a-task)
|
|
12
|
+
- [Re-planning](#re-planning)
|
|
13
|
+
- [When the claim cannot be written](#when-the-claim-cannot-be-written)
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
The roadmap is where a project says *what is being done and by whom*. With several
|
|
7
17
|
agents it is also the file most likely to be written by two of them at once, which is why
|
|
8
18
|
it is guarded — and why the tool touches it as narrowly as it possibly can.
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
**Read this when** starting a task, finishing one, or deciding where a piece of
|
|
4
4
|
documentation belongs.
|
|
5
5
|
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [They answer different questions](#they-answer-different-questions)
|
|
9
|
+
- [The duty, both ends of a task](#the-duty-both-ends-of-a-task)
|
|
10
|
+
- [What `reconcile` decides, and what it refuses to](#what-reconcile-decides-and-what-it-refuses-to)
|
|
11
|
+
- [The baseline — why the check is a ratchet](#the-baseline--why-the-check-is-a-ratchet)
|
|
12
|
+
- [Where a piece of documentation belongs](#where-a-piece-of-documentation-belongs)
|
|
13
|
+
- [Lifetime, and why nothing is deleted](#lifetime-and-why-nothing-is-deleted)
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
## They answer different questions
|
|
7
17
|
|
|
8
18
|
| Source | Answers | Written | Authority over |
|
|
Binary file
|
|
@@ -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.
|
|
36
|
+
VERSION = "1.8.1"
|
|
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
|
|
235
|
-
"""Every
|
|
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[
|
|
248
|
+
out: list[str] = []
|
|
238
249
|
for path in root.rglob("*"):
|
|
239
|
-
|
|
250
|
+
rel = path.relative_to(root)
|
|
251
|
+
if skip & set(rel.parts):
|
|
240
252
|
continue
|
|
241
|
-
if path.is_file()
|
|
242
|
-
out.append(
|
|
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 =
|
|
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 =
|
|
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
|
|