@ssheleg/agent-sync 1.2.2
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 +332 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/agent-sync.example.json +40 -0
- package/agent-sync.schema.json +144 -0
- package/bin/agent-sync.js +149 -0
- package/package.json +46 -0
- package/plugins/agent-sync/.claude-plugin/plugin.json +18 -0
- package/plugins/agent-sync/commands/agent-sync.md +16 -0
- package/plugins/agent-sync/hooks/_lib.sh +39 -0
- package/plugins/agent-sync/hooks/guard.sh +55 -0
- package/plugins/agent-sync/hooks/hooks.json +69 -0
- package/plugins/agent-sync/hooks/renew.sh +10 -0
- package/plugins/agent-sync/hooks/session-end.sh +14 -0
- package/plugins/agent-sync/hooks/session-start.sh +8 -0
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +372 -0
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +80 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-fs.md +57 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-outline.md +103 -0
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +99 -0
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +145 -0
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +76 -0
- package/plugins/agent-sync/skills/agent-sync/references/roadmap.md +103 -0
- package/plugins/agent-sync/skills/agent-sync/references/two-sources.md +131 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +2454 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Claude Code hooks
|
|
2
|
+
|
|
3
|
+
**Read this when** installing, debugging or removing the enforcement hooks.
|
|
4
|
+
|
|
5
|
+
## The limit, first
|
|
6
|
+
|
|
7
|
+
**Hooks exist only in Claude Code.** On Cursor, Codex and the other agents the
|
|
8
|
+
skills CLI serves there is no `PreToolUse`, so nothing blocks a guarded edit. On
|
|
9
|
+
those agents the same checks run as a self-check written into the skill body, and
|
|
10
|
+
the run is recorded on the board as `ungated`.
|
|
11
|
+
|
|
12
|
+
Never describe a project as protected when its agents run outside Claude Code. The
|
|
13
|
+
board's `gated` / `ungated` column exists precisely so that an operator can tell an
|
|
14
|
+
enforced run from a promised one.
|
|
15
|
+
|
|
16
|
+
## Contract
|
|
17
|
+
|
|
18
|
+
Verified against the Claude Code hooks reference, 2026-07-29.
|
|
19
|
+
|
|
20
|
+
A `PreToolUse` hook blocks a call in either of two ways:
|
|
21
|
+
|
|
22
|
+
- **exit 2**, with the reason on **stderr** (stdout is ignored); or
|
|
23
|
+
- **exit 0** with this on stdout:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{"hookSpecificOutput":{"hookEventName":"PreToolUse",
|
|
27
|
+
"permissionDecision":"deny",
|
|
28
|
+
"permissionDecisionReason":"agent-sync: docs/DECISIONS.md is held by run r-7f3a91"}}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Any other exit code is a non-blocking error: execution continues and stderr is shown
|
|
32
|
+
in the transcript. So **a crashing guard fails open** — write the guard to exit 2 on
|
|
33
|
+
its own internal errors, or it silently stops guarding.
|
|
34
|
+
|
|
35
|
+
The hook receives JSON on stdin with `session_id`, `prompt_id`, `transcript_path`,
|
|
36
|
+
`cwd`, `permission_mode`, `hook_event_name`, `tool_name`, `tool_input` and
|
|
37
|
+
`tool_use_id`.
|
|
38
|
+
|
|
39
|
+
## Installed hooks
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"hooks": {
|
|
44
|
+
"SessionStart": [
|
|
45
|
+
{ "matcher": "startup|resume",
|
|
46
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" }] }
|
|
47
|
+
],
|
|
48
|
+
"PreToolUse": [
|
|
49
|
+
{ "matcher": "Edit|Write|MultiEdit",
|
|
50
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] },
|
|
51
|
+
{ "matcher": "Bash", "if": "Bash(git commit *)",
|
|
52
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] }
|
|
53
|
+
],
|
|
54
|
+
"PostToolUse": [
|
|
55
|
+
{ "matcher": "*",
|
|
56
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/renew.sh" }] }
|
|
57
|
+
],
|
|
58
|
+
"SessionEnd": [
|
|
59
|
+
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-end.sh" }] }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Hook | Job |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `session-start.sh` | Register the run, print the board summary and the one next action |
|
|
68
|
+
| `guard.sh` | Deny an edit to a `guardedFiles[]` path, or a commit staging one, without a live lease |
|
|
69
|
+
| `renew.sh` | Renew the lease, throttled to `renewIntervalSeconds` — a no-op most calls |
|
|
70
|
+
| `session-end.sh` | Release every lease, flush the journal, close the run |
|
|
71
|
+
|
|
72
|
+
## Performance
|
|
73
|
+
|
|
74
|
+
`renew.sh` runs after **every** tool call. It must be a no-op in the common case:
|
|
75
|
+
it reads one timestamp file and returns. It touches the network at most once per
|
|
76
|
+
`renewIntervalSeconds` (default 300 s). If it ever becomes slower than that, the
|
|
77
|
+
throttle is broken — fix the throttle rather than removing the hook.
|
|
78
|
+
|
|
79
|
+
## Debugging
|
|
80
|
+
|
|
81
|
+
| Symptom | Cause |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Guarded edits go through | The guard crashed. Any exit code other than 2 is non-blocking. Run it by hand with a sample stdin payload |
|
|
84
|
+
| Everything is denied | No config, or no lease. `status` says which |
|
|
85
|
+
| Session start is slow | The backend is unreachable; it should time out at 5 s and degrade, not hang |
|
|
86
|
+
| Renew floods the log | The throttle file is not being written — check its path is writable |
|
|
87
|
+
|
|
88
|
+
Run the guard directly to see what it decides:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
echo '{"tool_name":"Edit","tool_input":{"file_path":"docs/DECISIONS.md"},"cwd":"'"$PWD"'"}' \
|
|
92
|
+
| "$CLAUDE_PLUGIN_ROOT/hooks/guard.sh"; echo "exit=$?"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Removing them
|
|
96
|
+
|
|
97
|
+
Delete the `hooks` block from the project's `.claude/settings.json`. The skill keeps
|
|
98
|
+
working — every guard is also available as a command, and the board simply records
|
|
99
|
+
runs as `ungated` from then on.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Lease and id-reservation protocol
|
|
2
|
+
|
|
3
|
+
**Read this when** changing acquisition, expiry, stealing, or id allocation — or
|
|
4
|
+
when two agents disagree about who holds something.
|
|
5
|
+
|
|
6
|
+
Two different mechanisms, and confusing them is how this went wrong twice:
|
|
7
|
+
|
|
8
|
+
> **A lease is decided by an atomic operation** — `O_EXCL` on one filesystem, or a pushed
|
|
9
|
+
> git ref across machines. **An id reservation is decided by replaying the log**, where
|
|
10
|
+
> allocation is positional and every reader computes the same answer.
|
|
11
|
+
|
|
12
|
+
The log never decides a lease. It records one, so other agents can see it.
|
|
13
|
+
|
|
14
|
+
## Line grammar
|
|
15
|
+
|
|
16
|
+
One event per line, appended, never edited. Exactly this shape:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
- `2026-07-29T10:42:13Z` `op=acquire` `key=ASC-072` `run=r-7f3a91` `ttl=2700` `repo=account-session-connect` `sha=9bba6d2`
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Parsed by:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
^[-*+] `(?P<ts>[^`]+)`(?P<pairs>(?: `[a-z_]+=[^`]*`)+)$
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Emit `- `; accept `-`, `*` or `+`.** The bullet is deliberately liberal because a
|
|
29
|
+
knowledge base normalises markdown on the way in — Outline rewrites `- ` to `* ` —
|
|
30
|
+
so a parser anchored to the character you wrote rejects every line the server hands
|
|
31
|
+
back. Observed live, and it presented as a lost race rather than a parse failure.
|
|
32
|
+
|
|
33
|
+
Required on every line: `op`, `key`, `run`. `op` is one of
|
|
34
|
+
`acquire` · `release` · `renew` · `base` · `reserve` · `release_id` · `signal` · `journal`.
|
|
35
|
+
|
|
36
|
+
Unparseable lines are **counted and reported**, never guessed at. Anything
|
|
37
|
+
entry-shaped (`^[-*+] \``) that fails the full pattern counts as unparseable; blank
|
|
38
|
+
lines, prose and the generated marker are skipped without counting.
|
|
39
|
+
|
|
40
|
+
**Do not put a narrower pre-filter in front of the pattern.** A `continue` that
|
|
41
|
+
tests for the exact bullet you emitted skips malformed lines *before* they can be
|
|
42
|
+
counted, so the ratio reads 0% while nothing parses — the guard and the counter both
|
|
43
|
+
go quiet at once. This is not hypothetical; it is how the bug above stayed invisible.
|
|
44
|
+
|
|
45
|
+
**An unreadable log is not a lost race.** When more than 2% of a log fails to parse,
|
|
46
|
+
`acquire` **raises** instead of reporting `lost`, and the board gate fails. Reporting
|
|
47
|
+
a lost race would name a holder who does not exist and send the caller looking for
|
|
48
|
+
them.
|
|
49
|
+
|
|
50
|
+
## Acquiring — the third design, and the first that is true
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
1. reap if .agent-sync/leases/<K>.lock exists and is expired, remove it
|
|
54
|
+
2. create os.open(lock, O_CREAT | O_EXCL) — this is the decision, and it is atomic
|
|
55
|
+
3. lost FileExistsError -> read the holder out of the file and report it
|
|
56
|
+
4. won write {run, ts, ttl, repo}; publish op=acquire to the plane for visibility
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Publishing is not the decision.** A failure to reach the knowledge base costs
|
|
60
|
+
visibility, never correctness: the lock is already held. So the append is wrapped and
|
|
61
|
+
its failure reported, not raised.
|
|
62
|
+
|
|
63
|
+
### Why not the knowledge base
|
|
64
|
+
|
|
65
|
+
Two earlier designs were measured and rejected. **One shared append-only document** loses
|
|
66
|
+
writes: twelve concurrent appends, twelve reported successes, three lines present — so a
|
|
67
|
+
lease decided on it can be held by two runs, each with proof. **One document per writer**
|
|
68
|
+
loses nothing (12/12) but cannot decide: without compare-and-swap nothing knows whether a
|
|
69
|
+
contender is still writing, and eight parallel processes each read only their own shard —
|
|
70
|
+
**eight winners for one key**. A longer settle window reached five, never one.
|
|
71
|
+
|
|
72
|
+
`O_EXCL` answers what the store cannot: twelve processes, one winner, eleven losers naming
|
|
73
|
+
the same holder. Full history in `CHANGELOG.md` (1.0.0).
|
|
74
|
+
|
|
75
|
+
### Cross-machine: `leaseBackend: "git"`
|
|
76
|
+
|
|
77
|
+
A lock file is exclusive between processes on **one filesystem**; two machines have two.
|
|
78
|
+
For cross-machine exclusion the lease is a commit pushed to
|
|
79
|
+
`refs/agent-sync/leases/<key>`, and the remote's non-fast-forward rejection **is** a
|
|
80
|
+
compare-and-swap — verified against a hosted remote, then proven with eight parallel
|
|
81
|
+
processes: one winner, seven losers naming it. Expired leases are stolen with
|
|
82
|
+
`--force-with-lease` against the exact object seen, so a steal cannot clobber a holder who
|
|
83
|
+
renewed in between.
|
|
84
|
+
|
|
85
|
+
The tool reports which guarantee is in force; it never implies the stronger one.
|
|
86
|
+
|
|
87
|
+
## Expiry and stealing
|
|
88
|
+
|
|
89
|
+
A lock is expired when `now > ts + ttl` for the timestamp inside it, refreshed by
|
|
90
|
+
`renew`.
|
|
91
|
+
|
|
92
|
+
Default `ttl` is 2700 s (45 minutes). `renew` is emitted at most once per
|
|
93
|
+
`renewIntervalSeconds` (default 300 s) — by the `PostToolUse` hook in Claude Code,
|
|
94
|
+
and by the agent itself everywhere else.
|
|
95
|
+
|
|
96
|
+
**Stealing an expired lease is the ordinary `acquire` path.** There is no force flag:
|
|
97
|
+
the reap step removes an expired lock and the create proceeds. The steal is visible on
|
|
98
|
+
the plane with both run ids, so an operator can see that it happened and when.
|
|
99
|
+
|
|
100
|
+
## Releasing
|
|
101
|
+
|
|
102
|
+
`release` on every path, including failure. An abandoned lease is indistinguishable
|
|
103
|
+
from active work until its TTL runs out, and during that window the task looks
|
|
104
|
+
taken. Report the failure and release; do not hold the lease "in case".
|
|
105
|
+
|
|
106
|
+
## Id reservation
|
|
107
|
+
|
|
108
|
+
Reading a "next free id" line from a file is not reserving it. Allocation is
|
|
109
|
+
**positional over the log**, so no agent has to trust another's arithmetic.
|
|
110
|
+
|
|
111
|
+
A register is opened once:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
- `…` `op=base` `key=DEC` `value=0216` `run=r-bootstrap`
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then, replaying in order and maintaining a free list:
|
|
118
|
+
|
|
119
|
+
- `op=release_id key=DEC value=NNNN` pushes `NNNN` onto the free list.
|
|
120
|
+
- `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise it
|
|
121
|
+
takes `base + (count of prior reserves not served from the free list)`.
|
|
122
|
+
|
|
123
|
+
Every reader computes the same assignment for every reserve line, including its own.
|
|
124
|
+
|
|
125
|
+
**An id you reserved and did not write to git must be released** with
|
|
126
|
+
`release_id`. An id that is reserved, unreleased and absent from git after its run
|
|
127
|
+
closes is reported by the board as a **leak** — never reclaimed automatically,
|
|
128
|
+
because a half-written decision on a branch is not the same thing as an unused
|
|
129
|
+
number, and silently handing it out again would produce two documents with one id.
|
|
130
|
+
|
|
131
|
+
## The lease is not the claim
|
|
132
|
+
|
|
133
|
+
| Fact | Home | Lifetime |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| Who holds this task **right now** | the lease log | ephemeral, TTL |
|
|
136
|
+
| Who **owns** this task | the git claim tag (`[name]`, `todo (claimed: <role>)`) | durable |
|
|
137
|
+
|
|
138
|
+
`acquire` writes the git tag through and `release` restores exactly what was there. The
|
|
139
|
+
objection that once demoted this to a check — an unattended process rewriting a shared
|
|
140
|
+
registry is the collision a lease exists to prevent — is engineered out rather than
|
|
141
|
+
accepted: one row, one cell, refused on ambiguity, atomic, and reversible from stored
|
|
142
|
+
state. See `roadmap.md`.
|
|
143
|
+
|
|
144
|
+
Do not add a third place that records ownership — a project with two claim vocabularies
|
|
145
|
+
has, in practice, none.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Binding to task-pipeline
|
|
2
|
+
|
|
3
|
+
**Read this when** wiring `pipeline.json`, or adding a stage hook.
|
|
4
|
+
|
|
5
|
+
`agent-sync` supplies stages; it does not define them. The stage names below are
|
|
6
|
+
`task-pipeline`'s own — do not rename, renumber or fork them.
|
|
7
|
+
|
|
8
|
+
## Where it plugs in
|
|
9
|
+
|
|
10
|
+
| Stage | Calls | Why there and not elsewhere |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| **0 Intake grill** | `status`, then `acquire <KEY>` | The cloud KB and the board join the harvest's source ledger. The lease is taken **before the brief is committed**, or two agents write two briefs for one task |
|
|
13
|
+
| **1 Docs study** | — | External docs; nothing shared to coordinate |
|
|
14
|
+
| **2 Brainstorm + decompose** | `journal` | Also warns when a live run holds an overlapping key — cheapest moment to find the overlap |
|
|
15
|
+
| **3 Spec** | `reserve <REG>` per id | Ids must be reserved *before* they are written to git. Reading "next free id" is not reserving it |
|
|
16
|
+
| **4 Plan** | `journal` with the plan's file ownership | Parallel groups that write one file are a merge conflict scheduled for later |
|
|
17
|
+
| **5 Dev** | `renew` (automatic), `journal` | Also the owner of the submodule-commit → parent-gitlink bump. Nobody else has both repos in hand |
|
|
18
|
+
| **6 Tests** | `journal` | The suite result is evidence, and evidence belongs in the run |
|
|
19
|
+
| **7 Lint + deploy** | `journal` per gate | — |
|
|
20
|
+
| **8 Post-deploy** | `journal` | — |
|
|
21
|
+
| **9 Docs + wiki** | `signal` per dependency flip, then `board` | The main write point. The pipeline already updates docs here; the board is regenerated from what it wrote |
|
|
22
|
+
| **10 Acceptance** | `release` every lease, write the claim tag through | A run that ends without releasing looks alive until its TTL expires |
|
|
23
|
+
|
|
24
|
+
## pipeline.json
|
|
25
|
+
|
|
26
|
+
`task-pipeline`'s `pipeline.schema.json` already permits this; nothing is forked.
|
|
27
|
+
Add `agent-sync` to `skills[]` on the six stages that call it:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"stages": [
|
|
32
|
+
{ "id": "0", "title": "Intake grill", "skills": ["task-pipeline:grill", "agent-sync"],
|
|
33
|
+
"gate": { "type": "manual", "check": "brief committed and lease held" } },
|
|
34
|
+
{ "id": "3", "title": "Spec", "skills": ["task-pipeline:spec", "agent-sync"],
|
|
35
|
+
"gate": { "type": "auto", "check": "every id in the spec was reserved" } },
|
|
36
|
+
{ "id": "4", "title": "Plan", "skills": ["task-pipeline:planning", "agent-sync"],
|
|
37
|
+
"gate": { "type": "auto", "check": "no two parallel tasks write one file" } },
|
|
38
|
+
{ "id": "5", "title": "Dev", "skills": ["task-pipeline:build", "agent-sync"],
|
|
39
|
+
"gate": { "type": "auto", "check": "lease live and submodule pointers current" } },
|
|
40
|
+
{ "id": "9", "title": "Docs + wiki", "skills": ["task-pipeline:artifacts", "agent-sync"],
|
|
41
|
+
"gate": { "type": "auto", "check": "board regenerated and no mirror drift" } },
|
|
42
|
+
{ "id": "10", "title": "Acceptance", "skills": ["task-pipeline:acceptance", "agent-sync"],
|
|
43
|
+
"gate": { "type": "auto", "check": "every lease released and every claim tag written through" } }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Stages 1, 2, 6, 7 and 8 keep their own `skills[]`; `agent-sync` only journals there,
|
|
49
|
+
which needs no wiring.
|
|
50
|
+
|
|
51
|
+
## Preflight
|
|
52
|
+
|
|
53
|
+
`task-pipeline` is required. When it is absent, print the install line and **stop** —
|
|
54
|
+
do not improvise a substitute flow, because without those stages there is nothing to
|
|
55
|
+
bind to and the result is ad-hoc work wearing a pipeline's vocabulary.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx sshlg-skills install
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That installer also brings `super-ux` (its stage-3 UX track is required for
|
|
62
|
+
user-facing work) and `make-skill`, and it prunes the duplicate plain-copy shadow in
|
|
63
|
+
`~/.claude/skills/` that otherwise serves a stale skill over the installed plugin.
|
|
64
|
+
|
|
65
|
+
## Gate expressions
|
|
66
|
+
|
|
67
|
+
Each `check` above is verified by the coordinator, not by prose:
|
|
68
|
+
|
|
69
|
+
| Check | How it is decided |
|
|
70
|
+
|---|---|
|
|
71
|
+
| lease held | replay the log; holder == this run |
|
|
72
|
+
| every id reserved | every `DEC-`/`OQ-`/`DEP-`-shaped token new in the diff has a `reserve` line in this run |
|
|
73
|
+
| no two parallel tasks write one file | intersect the file lists journaled at stage 4 |
|
|
74
|
+
| submodule pointers current | `git submodule status` reports no `+` prefix |
|
|
75
|
+
| board regenerated, no drift | each mirror stamp equals `git rev-parse HEAD` for its source |
|
|
76
|
+
| every lease released | replay the log; this run holds nothing |
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Working the roadmap: claims, closing, re-planning
|
|
2
|
+
|
|
3
|
+
**Read this when** configuring `claimTags`, taking or releasing a task, closing one, or
|
|
4
|
+
re-planning work that is already on a board.
|
|
5
|
+
|
|
6
|
+
The roadmap is where a project says *what is being done and by whom*. With several
|
|
7
|
+
agents it is also the file most likely to be written by two of them at once, which is why
|
|
8
|
+
it is guarded — and why the tool touches it as narrowly as it possibly can.
|
|
9
|
+
|
|
10
|
+
## The two records, and why both exist
|
|
11
|
+
|
|
12
|
+
| Record | Says | Lives | Lifetime |
|
|
13
|
+
|---|---|---|---|
|
|
14
|
+
| **Lease** | who holds this task *this minute* | the lease backend | expires by TTL |
|
|
15
|
+
| **Claim tag** | who *owns* this task | the roadmap, in git | until released or done |
|
|
16
|
+
|
|
17
|
+
A lease without a claim tag is invisible to anyone reading the repository. A claim tag
|
|
18
|
+
without a lease is a name that never expires — the stale `[Backend]` that blocks a task
|
|
19
|
+
for a week because someone's session died. **Both, or neither.**
|
|
20
|
+
|
|
21
|
+
`acquire` writes the tag through; `release` restores exactly what was there. You do not
|
|
22
|
+
write it by hand, and you should not: an editor rewriting a shared registry is the
|
|
23
|
+
collision the lease exists to prevent.
|
|
24
|
+
|
|
25
|
+
## How the write is made safe
|
|
26
|
+
|
|
27
|
+
The roadmap is a shared registry, so the edit is deliberately the smallest one possible:
|
|
28
|
+
|
|
29
|
+
1. **One row.** The tool finds the *single* markdown table row containing the task id as
|
|
30
|
+
a whole word. **Zero rows → nothing happens. Two or more → it refuses and says so.**
|
|
31
|
+
It never guesses which row you meant.
|
|
32
|
+
2. **One cell.** Only the configured cell of that row changes. Everything else on the
|
|
33
|
+
line — links, notes, other columns — is untouched, byte for byte.
|
|
34
|
+
3. **Reversible.** The previous cell text is stored in `.agent-sync/claims.json` and
|
|
35
|
+
restored verbatim on release. Not a default, not a guess: what was actually there.
|
|
36
|
+
4. **Atomic.** Written to a temporary file and moved into place, so a crash mid-write
|
|
37
|
+
cannot leave the register half-edited.
|
|
38
|
+
|
|
39
|
+
After `acquire` then `release`, `git diff` on the roadmap is **empty**. If it is not,
|
|
40
|
+
that is a bug, not a convention.
|
|
41
|
+
|
|
42
|
+
## Configuring it
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
"claimTags": {
|
|
46
|
+
"docs/WORKSTREAMS.md": { "mode": "cell", "cell": 2, "held": "{prev} · claimed {holder}" },
|
|
47
|
+
"apps/*/docs/ROADMAP.md": { "mode": "cell", "cell": -1, "held": "{prev} (claimed: {holder})" }
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `cell` is **0-based** over the row's cells. `-1` is the last cell, `-2` the one before
|
|
52
|
+
it. Getting this wrong writes the claim into the wrong column and looks like it worked —
|
|
53
|
+
check the result of your first `acquire` against the file before trusting the mapping.
|
|
54
|
+
- `held` is a template. `{prev}` is the cell's current text, `{holder}` the run id.
|
|
55
|
+
Keeping `{prev}` is what makes `todo` become `todo (claimed: r-x)` rather than losing
|
|
56
|
+
the status.
|
|
57
|
+
- A pattern may cover many files (`apps/*/docs/ROADMAP.md`); each is searched
|
|
58
|
+
independently, and a file with no matching row is simply skipped.
|
|
59
|
+
|
|
60
|
+
`check` validates that every pattern matches a real file and that the cell exists.
|
|
61
|
+
|
|
62
|
+
## Closing a task
|
|
63
|
+
|
|
64
|
+
Closing is **not** the same as releasing, and the tool does not do it for you.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
release <KEY> → the claim is removed; the row returns to what it said before
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That is the right behaviour when you stop working. **Closing** means the row's status
|
|
71
|
+
becomes `done` — a statement about the work, not about who was holding it, and one only
|
|
72
|
+
you can make. Do it as part of the same change that lands the work:
|
|
73
|
+
|
|
74
|
+
1. `record` what you actually built, with the decision id and the files;
|
|
75
|
+
2. edit the row's status to `done` yourself — you hold the lease, so the guard allows it;
|
|
76
|
+
3. `release` the lease;
|
|
77
|
+
4. `reconcile`, then `board`.
|
|
78
|
+
|
|
79
|
+
The tool refuses to write `done` on your behalf for the same reason it refuses to guess a
|
|
80
|
+
row: a status that a machine sets is a status nobody checked.
|
|
81
|
+
|
|
82
|
+
## Re-planning
|
|
83
|
+
|
|
84
|
+
New tasks, split tasks, re-ordered phases — all of it is an ordinary guarded edit:
|
|
85
|
+
|
|
86
|
+
- **Take a lease on the board itself** before restructuring it. Use the direction or
|
|
87
|
+
board id as the key (`WS-9`), not a task id, so the lease says what it means.
|
|
88
|
+
- **Reserve every new id** with `reserve` before writing it. Re-planning is exactly when
|
|
89
|
+
several agents mint ids at once, and reading a *next free id* line is not reserving it.
|
|
90
|
+
- **A task that moves keeps its id.** Ids are stable; renumbering breaks every reference
|
|
91
|
+
in every other document and in the as-built record.
|
|
92
|
+
- **Record the re-plan as a decision** if it changes scope. A board that quietly grew a
|
|
93
|
+
phase is a scope change nobody agreed to.
|
|
94
|
+
|
|
95
|
+
## When the claim cannot be written
|
|
96
|
+
|
|
97
|
+
If `acquire` prints nothing about the claim, no row matched — the task id is not on the
|
|
98
|
+
board, or not in a table row. That is worth noticing rather than ignoring: it usually
|
|
99
|
+
means the key you leased is not the key the board uses.
|
|
100
|
+
|
|
101
|
+
If it prints *refusing to guess which one*, two rows carry the id. Fix the board or
|
|
102
|
+
narrow the pattern; do not work around it by editing by hand, because the next agent's
|
|
103
|
+
lease will hit the same ambiguity.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Two documentation sources, and the duty to reconcile them
|
|
2
|
+
|
|
3
|
+
**Read this when** starting a task, finishing one, or deciding where a piece of
|
|
4
|
+
documentation belongs.
|
|
5
|
+
|
|
6
|
+
## They answer different questions
|
|
7
|
+
|
|
8
|
+
| Source | Answers | Written | Authority over |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| **Git docs** (`docs/`, ADRs, specs, contracts) | *How it should be* | before the code, often without it | intent, design, scope, decisions |
|
|
11
|
+
| **The coordination plane** (`70 As-built`) | *How it actually is* | from what agents really wrote | the record of what was built, by whom, at which commit |
|
|
12
|
+
|
|
13
|
+
Neither is a copy of the other and neither outranks the other, because neither is
|
|
14
|
+
answering the other's question. A spec that describes an unbuilt thing is not wrong —
|
|
15
|
+
it is intent. An as-built record that contradicts the spec is not wrong either — it is
|
|
16
|
+
what happened.
|
|
17
|
+
|
|
18
|
+
**The gap between them is the finding.** It is drift between plan and reality, and
|
|
19
|
+
surfacing it is the whole point. A system where the two can never disagree has simply
|
|
20
|
+
hidden the disagreement.
|
|
21
|
+
|
|
22
|
+
This is why the as-built record does **not** violate a single-source-of-truth rule:
|
|
23
|
+
there is no second home for one fact, there are two facts. What would violate it is
|
|
24
|
+
copying a decision's *body* into the cloud and editing it there.
|
|
25
|
+
|
|
26
|
+
## The duty, both ends of a task
|
|
27
|
+
|
|
28
|
+
**Before starting** — during the pipeline's docs-study stage:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" reconcile
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Read the git documents for the area you are about to touch **and** the as-built
|
|
35
|
+
record for it. Then resolve every divergence one of three ways:
|
|
36
|
+
|
|
37
|
+
1. the git document is stale → fix it, or record why it stands;
|
|
38
|
+
2. the as-built record is wrong or incomplete → correct it with `record`;
|
|
39
|
+
3. they genuinely disagree and the disagreement is real → that is a decision to make,
|
|
40
|
+
not a discrepancy to paper over. Raise it before you write code against either.
|
|
41
|
+
|
|
42
|
+
Starting work on top of an unresolved divergence means building against a document
|
|
43
|
+
that describes a system that does not exist.
|
|
44
|
+
|
|
45
|
+
**After finishing** — during the pipeline's docs stage:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" record "what you actually built" --decision DEC-0216 --files a.py,b.ts
|
|
49
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" reconcile
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Update **both** sides in the same change: the git documents that state intent, and the
|
|
53
|
+
as-built record of what landed. Then run the check again. A task that updated only one
|
|
54
|
+
side has left the next agent a divergence to discover the hard way.
|
|
55
|
+
|
|
56
|
+
## What `reconcile` decides, and what it refuses to
|
|
57
|
+
|
|
58
|
+
It is mechanical, and it says so in its own output. It finds:
|
|
59
|
+
|
|
60
|
+
- an as-built entry whose commit is **not in this repository's history** — recorded
|
|
61
|
+
from a branch that never landed, or from another repo;
|
|
62
|
+
- an id written in a register **after the baseline** with no as-built record — decided
|
|
63
|
+
since adoption, and nothing reports it was built;
|
|
64
|
+
- an as-built entry citing an id that **exists in no register** — built against
|
|
65
|
+
something never written down.
|
|
66
|
+
|
|
67
|
+
It does **not** judge whether the built thing matches what the document describes.
|
|
68
|
+
That is a reading, not a diff. The tool points at where to look and refuses to imply
|
|
69
|
+
it checked the substance.
|
|
70
|
+
|
|
71
|
+
## The baseline — why the check is a ratchet
|
|
72
|
+
|
|
73
|
+
A project adopting this on day one has every prior decision unrecorded. A check that
|
|
74
|
+
reports all of them reports nothing: it is noise, and noise is what gets a gate
|
|
75
|
+
switched off.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" reconcile --set-baseline
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run once per project. Ids at or below the baseline become a **backlog** — counted,
|
|
82
|
+
visible, allowed only to shrink. Ids after it must carry an as-built record or the
|
|
83
|
+
check fails. This is the same shape as a well-behaved lint ratchet, and for the same
|
|
84
|
+
reason: a gate that fails on history is a gate nobody keeps.
|
|
85
|
+
|
|
86
|
+
## Where a piece of documentation belongs
|
|
87
|
+
|
|
88
|
+
| It is… | Home |
|
|
89
|
+
|---|---|
|
|
90
|
+
| a decision about what to build | git — the decision register |
|
|
91
|
+
| a contract, schema, spec | git |
|
|
92
|
+
| user-facing behaviour | git |
|
|
93
|
+
| "this is what I implemented, here is the commit" | the as-built record |
|
|
94
|
+
| "the implementation diverged from the spec, here is why" | **both** — as-built for the fact, git for the decision |
|
|
95
|
+
| who is doing what right now | the claims log — ephemeral, never git |
|
|
96
|
+
|
|
97
|
+
When in doubt: if it must survive the tool being uninstalled, it goes in git.
|
|
98
|
+
|
|
99
|
+
## Lifetime, and why nothing is deleted
|
|
100
|
+
|
|
101
|
+
| Information | Home | Lifetime |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Decisions, specs, contracts, user-facing behaviour | git | permanent, append-only register |
|
|
104
|
+
| What was actually built, with its commit | as-built log | permanent, append-only |
|
|
105
|
+
| Cross-repo dependency state | signal log | permanent, append-only |
|
|
106
|
+
| Who holds a task right now | claims log | expires by TTL |
|
|
107
|
+
| Per-run narrative | that run's journal | permanent |
|
|
108
|
+
| The board, the repo page, the setup snapshot | generated | replaced on every regeneration |
|
|
109
|
+
|
|
110
|
+
**No log entry is ever edited or deleted.** The logs are replayed in order to decide who
|
|
111
|
+
holds what and which id was allocated, so removing a line silently rewrites a conclusion
|
|
112
|
+
every other agent has already acted on. Correct by **appending**:
|
|
113
|
+
|
|
114
|
+
- a lease is **released**, never removed;
|
|
115
|
+
- a reserved id you did not use is returned with `release-id`, which appends;
|
|
116
|
+
- a wrong as-built entry is superseded by a later, correct one, and both stay visible —
|
|
117
|
+
the history of what was believed is itself worth keeping.
|
|
118
|
+
|
|
119
|
+
Generated pages are the exception, and a narrow one: they are rewritten wholesale, and a
|
|
120
|
+
page whose first line has lost its `agent-sync:generated` marker is **refused** rather
|
|
121
|
+
than overwritten, because a human took it over.
|
|
122
|
+
|
|
123
|
+
**Growth.** These logs are small — one line per event — so rotation is not urgent. When a
|
|
124
|
+
log does need trimming, archive the whole document and start a fresh one with a `base`
|
|
125
|
+
line carrying the current allocation state. Never delete lines from a live log to shrink
|
|
126
|
+
it: replay would then produce a different answer than it did yesterday.
|
|
127
|
+
|
|
128
|
+
**Removing the tool.** Everything durable is already in git. Delete `.agent-sync/`, the
|
|
129
|
+
config and the env file; the knowledge-base pages can be kept as a record or archived.
|
|
130
|
+
Nothing in the repository depends on the tool being installed, which is the property that
|
|
131
|
+
makes adopting it reversible.
|