@ssheleg/agent-sync 1.7.0 → 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 +139 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/commands/agent-sync.md +2 -2
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +14 -19
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +8 -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 +94 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
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
|
+
|
|
75
|
+
## v1.7.1
|
|
76
|
+
|
|
77
|
+
**A second audit, along a different axis: not whether the tool keeps its promises, but whether an
|
|
78
|
+
agent reading this skill can follow it and get a correct result.** The scenarios were executed, not
|
|
79
|
+
imagined — cold start, the whole adoption chain, the per-task cycle, two agents contending for one
|
|
80
|
+
task, every hook with a realistic payload, and the edges (no git repository, uninitialised project,
|
|
81
|
+
absolute paths, `git -C <dir> commit`). The coordination core came through clean. What did not was
|
|
82
|
+
everything the agent *reads*.
|
|
83
|
+
|
|
84
|
+
### The documents the tool generates were two versions behind the doctrine it teaches
|
|
85
|
+
|
|
86
|
+
`setup` writes the snapshot every agent is told to read first — *"it states how documentation and
|
|
87
|
+
coordination work here"* — and `scaffold` seeds `AGENTS.md`. Neither mentioned a branch or `merge`
|
|
88
|
+
even once. Both prescribed a cycle ending in `release`, and the snapshot stated *"the claim tag in
|
|
89
|
+
git is written through"* as an unconditional fact, which has been false on any branch since 1.4.0 —
|
|
90
|
+
the branch being where the doctrine says the work belongs.
|
|
91
|
+
|
|
92
|
+
So an agent doing exactly what the skill instructs — trust the generated snapshot — got the
|
|
93
|
+
workflow from two releases ago. Regenerating did not help: the generator was what was stale. Both
|
|
94
|
+
now carry the branch rule, `merge --key`, and this project's integration branch by name.
|
|
95
|
+
|
|
96
|
+
`AGENTS.md` stopped restating the cycle altogether. It is seeded once and **never overwritten**, so
|
|
97
|
+
a copy of the protocol there is frozen on the day the project was created while the tool moves on —
|
|
98
|
+
and every project scaffolded before today would have kept the old one forever. It now points at the
|
|
99
|
+
snapshot, which is regenerable and which `check` fails on when it goes stale. One fact, one home.
|
|
100
|
+
|
|
101
|
+
### `check` blessed a project an agent cannot work in
|
|
102
|
+
|
|
103
|
+
A configuration declaring `idRegisters` on a backend whose `reserve` always raises passed as
|
|
104
|
+
`setup healthy`. `check`'s own promise is that it refuses a rule pointing at what is not there, and
|
|
105
|
+
a register nobody can allocate from is exactly that — while the snapshot it generates instructs
|
|
106
|
+
every agent to run `agent_sync.py reserve DEC`, which cannot succeed there. It is now a problem,
|
|
107
|
+
named with both ways out.
|
|
108
|
+
|
|
109
|
+
### The slash command offered a verb the CLI does not have
|
|
110
|
+
|
|
111
|
+
`argument-hint` advertised `claim <KEY>` and the README showed `/agent-sync claim ASC-072`. The
|
|
112
|
+
command is `acquire`; `claim` is an `invalid choice`. First thing an agent reads, first thing it
|
|
113
|
+
types.
|
|
114
|
+
|
|
115
|
+
### `$SKILL_DIR` was used in every example and defined nowhere
|
|
116
|
+
|
|
117
|
+
Six invocations tell the agent to run `python3 "$SKILL_DIR/scripts/agent_sync.py"`, and the only
|
|
118
|
+
explanation was the prose *"this skill's own directory"*. Nothing gave a value. The Cursor rule
|
|
119
|
+
names a concrete path; the skill body now names both — `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
|
|
120
|
+
and `~/.agents/skills/agent-sync`.
|
|
121
|
+
|
|
122
|
+
### What the audit found working
|
|
123
|
+
|
|
124
|
+
Worth recording, because a report that only lists faults says nothing about the rest: two agents
|
|
125
|
+
contending for one task behave correctly end to end — the second loses, sees who holds it and in
|
|
126
|
+
which repository, is denied the guarded file, and cannot release a lease it does not hold. The
|
|
127
|
+
guard denies `Edit`, `Write`, `NotebookEdit` and absolute paths, and blocks `git commit`,
|
|
128
|
+
`git -C <dir> commit` and `cd <dir> && git commit` when a guarded file is staged, while letting
|
|
129
|
+
`git log --grep=commit` and malformed JSON through. `SessionStart` stamps the identity and prints
|
|
130
|
+
the awareness block; `SessionEnd` releases. The adoption chain works with and without a pre-existing
|
|
131
|
+
`docs/`. An uninitialised project and a non-git directory both answer with the next action and a
|
|
132
|
+
non-zero exit.
|
|
133
|
+
|
|
134
|
+
### New checks
|
|
135
|
+
|
|
136
|
+
`check_every_advertised_verb_exists`, `check_generated_docs_carry_current_doctrine`,
|
|
137
|
+
`check_registers_need_a_backend_that_can_reserve`, `check_skill_gives_a_resolvable_script_path` —
|
|
138
|
+
with four more self-test fixtures. The validator now plants and catches 32 distinct defects.
|
|
139
|
+
|
|
1
140
|
## v1.7.0
|
|
2
141
|
|
|
3
142
|
**Observability, honest degradation, and the removal of things that were never load-bearing.**
|
package/README.md
CHANGED
|
@@ -225,8 +225,9 @@ action:
|
|
|
225
225
|
|
|
226
226
|
## Everyday use
|
|
227
227
|
|
|
228
|
-
In an agent session you use the slash command (`/agent-sync
|
|
229
|
-
|
|
228
|
+
In an agent session you use the slash command (`/agent-sync acquire ASC-072` — the verb is
|
|
229
|
+
`acquire`, the same one the CLI takes); the same commands run directly against the
|
|
230
|
+
coordinator script, which is what the hooks and CI do:
|
|
230
231
|
|
|
231
232
|
```bash
|
|
232
233
|
python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "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.
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Coordinate concurrent agents — initialise the shared knowledge store, check status,
|
|
3
|
-
argument-hint: "[init|status|
|
|
2
|
+
description: Coordinate concurrent agents — initialise the shared knowledge store, check status, take a task, reserve an id, or regenerate the board.
|
|
3
|
+
argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|board|merge|finish]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Invoke the `agent-sync` skill.
|
|
@@ -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.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -113,10 +113,9 @@ storage question gets asked and answered, once, and written down.
|
|
|
113
113
|
- or local files (`fs`) — no credentials, and no visibility to an agent on another
|
|
114
114
|
machine: no shared awareness, no cross-repo signals, no shared board.
|
|
115
115
|
|
|
116
|
-
The lease is decided separately by `leaseBackend`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
with a local lock is *not* exclusion across them. Report the one you actually have.
|
|
116
|
+
The lease is decided separately by `leaseBackend` (trap 2), and **`gated` follows that
|
|
117
|
+
choice, never the record plane**. Report the guarantee you actually have, not the
|
|
118
|
+
stronger one the record plane suggests.
|
|
120
119
|
2. **If cloud: the instance URL.** The URL is configuration, not a secret, so you
|
|
121
120
|
may write it. The **token is not** — you never ask for it in chat, never read it
|
|
122
121
|
back, and never place it yourself.
|
|
@@ -144,9 +143,9 @@ the file is their step, and the design depends on it staying theirs.
|
|
|
144
143
|
python3 "$SKILL_DIR/scripts/agent_sync.py" status
|
|
145
144
|
```
|
|
146
145
|
|
|
147
|
-
Idempotent. Inspects, repairs
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
Idempotent. Inspects, repairs, reports, names exactly ONE next action — and carries
|
|
147
|
+
`check`'s verdict, so the command every session runs and the one that validates the
|
|
148
|
+
setup cannot give two answers about one project.
|
|
150
149
|
|
|
151
150
|
**Read the two awareness sections it prints — they are the point, not decoration.**
|
|
152
151
|
|
|
@@ -196,7 +195,9 @@ npx sshlg-skills install
|
|
|
196
195
|
| `scaffold [--full]` | Create only what is missing, never a line over anything that exists. `--full` also seeds the question register, the index, the dependency board, the data model with its entity register, and the docs gate |
|
|
197
196
|
| `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, no lease left held. `check` answers whether the project is wired correctly; this answers whether you are done |
|
|
198
197
|
|
|
199
|
-
`$SKILL_DIR` is this skill's own directory
|
|
198
|
+
`$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
|
|
199
|
+
under the Claude Code plugin, `~/.agents/skills/agent-sync` elsewhere. Resolve it once
|
|
200
|
+
per session and reuse it — do not guess a path. Every command reads
|
|
200
201
|
`.claude/agent-sync.json` from the project root and needs no arguments beyond those
|
|
201
202
|
listed.
|
|
202
203
|
|
|
@@ -321,16 +322,10 @@ Two files, and the split between them is the whole security model.
|
|
|
321
322
|
**`.claude/agent-sync.json`** — *shape*, committed: which backend, TTLs, which files
|
|
322
323
|
are guarded, which registers exist, which gates to run.
|
|
323
324
|
|
|
324
|
-
**`.env.agent-sync`** — *identity*,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
AGENT_SYNC_OUTLINE_URL=https://<instance>
|
|
329
|
-
AGENT_SYNC_OUTLINE_TOKEN= # the operator fills this line, nobody else
|
|
330
|
-
AGENT_SYNC_OUTLINE_COLLECTION= # printed by `bootstrap`
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
Load it before running agents:
|
|
325
|
+
**`.env.agent-sync`** — *identity*, written by `init` with the keys already in it, mode
|
|
326
|
+
600, gitignored. The operator fills the token line and nobody else; `bootstrap` prints
|
|
327
|
+
the collection id to paste beside it. Found here, or in a superproject, or wherever
|
|
328
|
+
`AGENT_SYNC_ENV` points — `check` prints which. Load it before running agents:
|
|
334
329
|
|
|
335
330
|
```bash
|
|
336
331
|
set -a && . ./.env.agent-sync && set +a
|
|
@@ -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
|
|
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.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
|
|
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:
|
|
@@ -2094,19 +2117,29 @@ class Sync:
|
|
|
2094
2117
|
"## The cycle, per task",
|
|
2095
2118
|
"",
|
|
2096
2119
|
"```",
|
|
2097
|
-
"
|
|
2120
|
+
"merges → what landed while you were away",
|
|
2121
|
+
"status → who else is working, and what changed since you last looked",
|
|
2098
2122
|
"reconcile → resolve every divergence BEFORE writing code",
|
|
2099
|
-
"
|
|
2123
|
+
"branch → work happens on one; the integration branch is somebody",
|
|
2124
|
+
" else's stable base",
|
|
2125
|
+
"acquire ID → take the lease. On the integration branch the claim tag is",
|
|
2126
|
+
" written through to git; on any other branch the holder stays",
|
|
2127
|
+
" in the coordination plane, where `status` shows it to everyone",
|
|
2100
2128
|
" … work …",
|
|
2101
2129
|
"record → what you ACTUALLY built, with the decision id and files",
|
|
2102
2130
|
" … update the git documents in the same change …",
|
|
2103
2131
|
"reconcile → check both sides again",
|
|
2104
2132
|
"board → regenerate the shared view",
|
|
2105
|
-
"
|
|
2133
|
+
"merge --key → land the branch: conflicts checked first, the merge recorded,",
|
|
2134
|
+
" that lease released. Without a branch, `release ID` by hand",
|
|
2135
|
+
" — on every path, including failure",
|
|
2106
2136
|
"```",
|
|
2107
2137
|
"",
|
|
2138
|
+
f"This project's integration branch is `{self.integration_branch}`.",
|
|
2139
|
+
"",
|
|
2108
2140
|
"Full doctrine ships with the skill: `references/two-sources.md`,",
|
|
2109
|
-
"`references/lease-protocol.md`, `references/
|
|
2141
|
+
"`references/lease-protocol.md`, `references/branching.md`,",
|
|
2142
|
+
"`references/roadmap.md`, `references/pipeline-binding.md`.",
|
|
2110
2143
|
]
|
|
2111
2144
|
return "\n".join(L) + "\n"
|
|
2112
2145
|
|
|
@@ -2889,31 +2922,24 @@ annotate the old entry's status line. The body of the old entry stays as history
|
|
|
2889
2922
|
|
|
2890
2923
|
AGENTS_SEED = """# AGENTS.md — working protocol
|
|
2891
2924
|
|
|
2892
|
-
**Read [`{snapshot}`]({snapshot}) first
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
## Before you write anything
|
|
2897
|
-
|
|
2898
|
-
Several agents may work this repository at once.
|
|
2925
|
+
**Read [`{snapshot}`]({snapshot}) first, and follow the cycle it states.** That file is
|
|
2926
|
+
generated from the live configuration: which registers exist, which files need a lease,
|
|
2927
|
+
which gates run, what is written where, what is never deleted, and the order to do it in.
|
|
2899
2928
|
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2929
|
+
This file deliberately does **not** restate that cycle. It is seeded once and never
|
|
2930
|
+
overwritten, so a copy of the protocol here would be frozen on the day the project was
|
|
2931
|
+
created while the tool moved on — and the two would disagree in front of an agent with no
|
|
2932
|
+
way to tell which is current. One fact, one home; the home is the generated snapshot,
|
|
2933
|
+
because it can be regenerated and `agent-sync check` fails when it goes stale.
|
|
2905
2934
|
|
|
2906
|
-
##
|
|
2935
|
+
## The three that are true in every project
|
|
2907
2936
|
|
|
2908
|
-
1.
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
**No decision lives only in chat.** Record it in the decision register, propagate it to
|
|
2916
|
-
every document it affects, and commit referencing the id.
|
|
2937
|
+
1. **Several agents may work this repository at once.** `agent-sync status` before you
|
|
2938
|
+
start: who holds what, and what changed since you last looked.
|
|
2939
|
+
2. **Work on a branch, and land it with `agent-sync merge`.** The integration branch is
|
|
2940
|
+
somebody else's stable base — nothing about work in flight is committed there.
|
|
2941
|
+
3. **No decision lives only in chat.** Record it in the decision register, propagate it to
|
|
2942
|
+
every document it affects, and commit referencing the id.
|
|
2917
2943
|
"""
|
|
2918
2944
|
|
|
2919
2945
|
|
|
@@ -3119,8 +3145,21 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
|
|
|
3119
3145
|
for k in sorted(set(cfg) - CONFIG_KEYS):
|
|
3120
3146
|
problems.append(f"config key '{k}' is not in the schema — it will be ignored")
|
|
3121
3147
|
|
|
3122
|
-
# Registers must exist
|
|
3148
|
+
# Registers must exist, their allocation pattern must match — and the backend must be
|
|
3149
|
+
# able to hand an id out at all. A register declared on a plane whose `reserve` always
|
|
3150
|
+
# raises is a rule that protects nothing, which is the exact thing `check` promises to
|
|
3151
|
+
# refuse: the generated snapshot then instructs every agent to run `reserve <REG>`, and
|
|
3152
|
+
# the command cannot succeed in that project.
|
|
3123
3153
|
regs = cfg.get("idRegisters") or {}
|
|
3154
|
+
if regs:
|
|
3155
|
+
backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
|
|
3156
|
+
probe = make_adapter(cfg, root)
|
|
3157
|
+
if not probe.is_lease_authority:
|
|
3158
|
+
problems.append(
|
|
3159
|
+
f"{len(regs)} id register(s) declared, but backend '{probe.name}' cannot "
|
|
3160
|
+
f"reserve ids (atomicAppend is false){' — the configured backend is ' + backend + ' and it is not reachable, so runs degrade to fs' if backend != probe.name else ''}. "
|
|
3161
|
+
"`reserve` fails every time here; either configure a backend that can "
|
|
3162
|
+
"allocate, or remove the registers and allocate them in the parent repository")
|
|
3124
3163
|
for reg, spec in sorted(regs.items()):
|
|
3125
3164
|
f = root / spec.get("file", "")
|
|
3126
3165
|
if not f.exists():
|
|
@@ -3139,9 +3178,11 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
|
|
|
3139
3178
|
ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
|
|
3140
3179
|
|
|
3141
3180
|
# A guard glob that matches nothing protects nothing. Resolved by the same function
|
|
3142
|
-
# 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 []))
|
|
3143
3184
|
for pattern in (cfg.get("guardedFiles") or []):
|
|
3144
|
-
hits =
|
|
3185
|
+
hits = guard_hits.get(pattern) or []
|
|
3145
3186
|
if not hits:
|
|
3146
3187
|
problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
|
|
3147
3188
|
if cfg.get("guardedFiles"):
|
|
@@ -3149,8 +3190,9 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
|
|
|
3149
3190
|
else:
|
|
3150
3191
|
warn.append("no guarded files — nothing requires a lease in this repository")
|
|
3151
3192
|
|
|
3193
|
+
claim_hits = glob_files_many(root, list(cfg.get("claimTags") or {}))
|
|
3152
3194
|
for pattern, spec in (cfg.get("claimTags") or {}).items():
|
|
3153
|
-
files =
|
|
3195
|
+
files = claim_hits.get(pattern) or []
|
|
3154
3196
|
if not files:
|
|
3155
3197
|
problems.append(f"claimTags pattern '{pattern}' matches no file")
|
|
3156
3198
|
continue
|
|
@@ -3381,6 +3423,21 @@ def cmd_merge(args: argparse.Namespace) -> int:
|
|
|
3381
3423
|
raise Fail(f"working tree is not clean ({len(dirty)} path(s)) — commit or stash first; "
|
|
3382
3424
|
"a merge cannot tell your uncommitted work from the branch's")
|
|
3383
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
|
+
|
|
3384
3441
|
git("fetch", "--quiet", "origin", target)
|
|
3385
3442
|
upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
|
|
3386
3443
|
|