@ssheleg/agent-sync 1.3.5 → 1.4.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 +128 -0
- package/LICENSE +1 -1
- package/README.md +48 -8
- package/agent-sync.example.json +7 -2
- package/agent-sync.schema.json +21 -1
- package/bin/agent-sync.js +1 -1
- package/package.json +5 -5
- package/plugins/agent-sync/.claude-plugin/plugin.json +4 -4
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +48 -50
- package/plugins/agent-sync/skills/agent-sync/references/branching.md +108 -0
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +1 -1
- 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 +282 -1
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-311.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-314.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-39.pyc +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,134 @@
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
This project adheres to [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 1.4.1 — 2026-07-30
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **`homepage` and `repository` sat at the top level of `marketplace.json`,
|
|
10
|
+
where Claude Code does not recognize them.** They are plugin-entry fields;
|
|
11
|
+
moved there, so the values reach the plugin listing instead of being ignored.
|
|
12
|
+
This plugin's `argument-hint` was already quoted — the only one in the family
|
|
13
|
+
that was.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `claude plugin validate --strict` runs in CI against both the plugin and the
|
|
17
|
+
marketplace manifest: the upstream schema, next to this repo's own validator.
|
|
18
|
+
|
|
19
|
+
## 1.4.0 — 2026-07-30
|
|
20
|
+
|
|
21
|
+
### Work happens on a branch, and the integration branch stays somebody else's stable base
|
|
22
|
+
|
|
23
|
+
Two agents renamed this repository the same minute yesterday, both committing straight to
|
|
24
|
+
`main`. The second push was rejected — after the work was already duplicated. Nothing in
|
|
25
|
+
the tool said where work should happen, so both did the obvious thing.
|
|
26
|
+
|
|
27
|
+
- **`acquire` writes the claim through only on the integration branch.** On any other
|
|
28
|
+
branch it says so and writes nothing: a claim committed to a branch is invisible to
|
|
29
|
+
every other agent until the merge, and it turns the shared roadmap into a file two
|
|
30
|
+
branches both edit — a conflict on the one file that exists to prevent collisions. The
|
|
31
|
+
holder lives in the coordination plane, where `status` already shows it to everyone
|
|
32
|
+
without anyone fetching a branch.
|
|
33
|
+
- **`integrationBranch`** in the config, or the repository's own default branch when unset.
|
|
34
|
+
Asked of the repository, never assumed.
|
|
35
|
+
|
|
36
|
+
### `merge` — land a branch, with every check before anything is touched
|
|
37
|
+
|
|
38
|
+
`merge` refuses a detached HEAD, the integration branch itself and a dirty tree; fetches
|
|
39
|
+
the target and reports how far it moved; computes conflicts with **`git merge-tree`, in
|
|
40
|
+
memory**; lists every other run's live lease; merges `--no-ff`; records the merge; and
|
|
41
|
+
releases every lease this run holds. On conflict it names the files, changes nothing and
|
|
42
|
+
exits non-zero — a merge that starts and aborts leaves the operator in a repository they
|
|
43
|
+
did not ask for, and a resolution nobody reviewed does not belong on the integration
|
|
44
|
+
branch. `--dry-run` stops after the checks, `--push` pushes afterwards.
|
|
45
|
+
|
|
46
|
+
### `merges` — what landed while you were on your branch
|
|
47
|
+
|
|
48
|
+
A merge log at `docs/MERGES.md` (`mergeLog.file`), written by `merge`. It answers what
|
|
49
|
+
`git log` cannot without reading every commit and what a changelog only covers once
|
|
50
|
+
released: *what landed while I was away, and was any of it near my work.*
|
|
51
|
+
|
|
52
|
+
**Compaction happens on write.** Entries inside `mergeLog.retentionDays` (7) keep their
|
|
53
|
+
detail; older ones fold to one line each on the next merge. No cron, no second command,
|
|
54
|
+
and no log that grows until people stop reading it — which is the same as not having one.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- `test/validate.py` exercises both rules against throwaway repositories: `acquire` on a
|
|
58
|
+
feature branch must leave the roadmap untouched **and** say where the claim lives, while
|
|
59
|
+
on the integration branch it must still write through; a conflicting `merge` must exit
|
|
60
|
+
non-zero, name the conflicting file, leave the checkout on the original branch with a
|
|
61
|
+
clean tree, and write **no** log entry for a merge that did not happen. Both have
|
|
62
|
+
self-test fixtures that remove the guard and confirm the check goes red.
|
|
63
|
+
- `references/branching.md`, and the doctrine in the skill body.
|
|
64
|
+
|
|
65
|
+
## 1.3.9 — 2026-07-30
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- `license: MIT` added to the `marketplace.json` plugin entry. The front matter
|
|
69
|
+
already declared it — this repo was the only one in the family that did — but
|
|
70
|
+
the plugin listing, which is what a user reads before installing, did not.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## 1.3.8 — 2026-07-30
|
|
74
|
+
|
|
75
|
+
The 1.3.6 move was performed twice, by two agents in this repository within the same
|
|
76
|
+
minute, and neither held a lease on it. Both renames were correct and nearly identical —
|
|
77
|
+
the duplicated effort is the cost, and this project exists to make it visible. This
|
|
78
|
+
release carries the two things only one of them had, and it is the release that moves the
|
|
79
|
+
npm package page: 1.3.6 and 1.3.7 were tagged and never published.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
- **`LICENSE` and the README footer read `ssheleg`.** The rename moved every address and
|
|
83
|
+
every manifest identity, and left the copyright line naming the previous owner — the one
|
|
84
|
+
statement in this repository with legal weight.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
- **`test/validate.py` fails a half-finished rename.** It derives the canonical slug from
|
|
88
|
+
`package.json` and rejects any other owner of this repository name anywhere in the tree:
|
|
89
|
+
in a URL, in a `github:` install argument, in a `marketplace add` line, or as a bare
|
|
90
|
+
quoted slug. That last shape is what the installers actually clone, and the first draft
|
|
91
|
+
of the check missed it — `bin/agent-sync.js` and `install.sh` both passed while still
|
|
92
|
+
pointing at the old owner. Verified red against the pre-rename tree; the self-test
|
|
93
|
+
fixture assembles its slug from parts, because written whole it makes the validator the
|
|
94
|
+
file that fails the check.
|
|
95
|
+
|
|
96
|
+
## 1.3.7 — 2026-07-30
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
- **The 1.3.6 note below claimed `raw.githubusercontent.com` does not follow a
|
|
100
|
+
repository transfer. It does** — the old owner's raw path returns 200 with the
|
|
101
|
+
current file, no redirect involved, because GitHub resolves a transferred
|
|
102
|
+
repository by identity on every surface (git, web and raw alike). Measured
|
|
103
|
+
after the move, which is what the claim should have been before it was
|
|
104
|
+
written. The paragraph is corrected in place so no install ships the wrong
|
|
105
|
+
fact; the reason to update the URLs is unchanged and stated accurately there.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## 1.3.6 — 2026-07-30
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
|
|
112
|
+
The repository moved from `appvillis-com/agent-sync` to **`ssheleg/agent-sync`**,
|
|
113
|
+
joining the rest of the family under one owner. GitHub keeps serving the old path
|
|
114
|
+
on every surface, so nothing breaks today — but that only holds while the
|
|
115
|
+
`appvillis-com` name is never re-registered, and a reference that depends on
|
|
116
|
+
somebody else not taking a name is worth one commit to remove.
|
|
117
|
+
|
|
118
|
+
- **Install paths** — `install.sh`, `bin/agent-sync.js` (the `npx github:…`
|
|
119
|
+
fallback), and the README's npx and `claude plugin marketplace add` commands.
|
|
120
|
+
- **Identity** — `package.json` (homepage, repository, bugs, author),
|
|
121
|
+
`.claude-plugin/marketplace.json` (owner, homepage, repository, plugin author),
|
|
122
|
+
`plugins/agent-sync/.claude-plugin/plugin.json`, and the `author` in the skill's
|
|
123
|
+
front matter: `appvillis-com` → `ssheleg`.
|
|
124
|
+
- **Raw URLs** — `agent-sync.schema.json` `$id`, the `$schema` in
|
|
125
|
+
`agent-sync.example.json`, and the reference-loading fallback URL in `SKILL.md`.
|
|
126
|
+
A `$id` is an identifier as much as a location, so it should name the repository
|
|
127
|
+
that actually holds the schema.
|
|
128
|
+
- Cursor rule, code of conduct and the security-advisory link in the issue-template
|
|
129
|
+
config.
|
|
130
|
+
|
|
131
|
+
The changelog entries below and `docs/superpowers/specs/` keep the old path on
|
|
132
|
+
purpose: they record where this was published at the time.
|
|
133
|
+
|
|
6
134
|
## 1.3.5 — 2026-07-29
|
|
7
135
|
|
|
8
136
|
### The two rules this plugin enforces are now stated, with the failures that taught them
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# agent-sync
|
|
2
2
|
|
|
3
|
-
[](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@ssheleg/agent-sync)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
@@ -112,7 +112,7 @@ Restart Claude Code after installing, so it picks the plugin up.
|
|
|
112
112
|
Track `main` from GitHub instead of the npm release:
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
npx github:
|
|
115
|
+
npx github:ssheleg/agent-sync install
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
Claude Code only, no skills CLI:
|
|
@@ -130,7 +130,7 @@ npx @ssheleg/agent-sync install --agent cursor,codex
|
|
|
130
130
|
Or add the plugin by hand — the full `<name>@<name>` form is required:
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
claude plugin marketplace add
|
|
133
|
+
claude plugin marketplace add ssheleg/agent-sync && claude plugin install agent-sync@agent-sync
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
</details>
|
|
@@ -247,16 +247,53 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
|
|
|
247
247
|
| `guard <path>` | May this run write that path? Exit 0 = yes, 2 = no |
|
|
248
248
|
| `board` | Regenerate the read-only board and the mirror from git |
|
|
249
249
|
| `whoami` | Print this run's id and its held leases |
|
|
250
|
+
| `merge` | Land this branch on the integration branch: conflicts checked first, merge log written, lease released |
|
|
251
|
+
| `merges` | What landed while you were on your branch |
|
|
250
252
|
|
|
251
253
|
The shape that matters:
|
|
252
254
|
|
|
253
255
|
```
|
|
254
|
-
acquire →
|
|
256
|
+
acquire → work on a branch → merge
|
|
255
257
|
```
|
|
256
258
|
|
|
257
|
-
Never skip
|
|
258
|
-
until its TTL expires, and the next agent cannot tell "in progress" from "crashed an
|
|
259
|
-
ago". A lease is a promise to come back.
|
|
259
|
+
Never skip the last step, including when the work failed. An abandoned lease blocks the
|
|
260
|
+
task until its TTL expires, and the next agent cannot tell "in progress" from "crashed an
|
|
261
|
+
hour ago". A lease is a promise to come back. `merge` releases for you; without a branch,
|
|
262
|
+
`release` by hand.
|
|
263
|
+
|
|
264
|
+
### Work on a branch, land it with `merge`
|
|
265
|
+
|
|
266
|
+
The integration branch is somebody else's stable base, so nothing about work in flight is
|
|
267
|
+
committed there. `acquire` writes the claim through to the roadmap **only** on the
|
|
268
|
+
integration branch; on any other branch it says so and keeps the holder in the coordination
|
|
269
|
+
plane, where `status` already shows it to every agent — no one has to fetch your branch to
|
|
270
|
+
see who has what, and the shared roadmap does not become the file every branch edits.
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merge --key ASC-072 --summary "what landed"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Conflicts are computed with `git merge-tree` **before anything is touched** — on conflict
|
|
277
|
+
it names the files, changes nothing and exits non-zero, so a resolution nobody reviewed
|
|
278
|
+
never reaches the integration branch. Then it merges `--no-ff`, records the merge, and
|
|
279
|
+
releases every lease this run holds. `--dry-run` stops after the checks; `--push` pushes
|
|
280
|
+
afterwards.
|
|
281
|
+
|
|
282
|
+
**The merge log** — `docs/MERGES.md`, configurable via `mergeLog` — answers the question an
|
|
283
|
+
agent coming back from a branch cannot answer from `git log` alone: *what landed while I
|
|
284
|
+
was away, and was any of it near my work.* Entries inside `retentionDays` (7) keep their
|
|
285
|
+
detail; older ones are folded to one line each **on the next write**, so the file stays
|
|
286
|
+
readable without a cron job.
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merges # recent detail
|
|
290
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merges --all # plus the compacted tail
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
This is the flow for agents sharing one repository. A project that reviews through pull
|
|
294
|
+
requests keeps doing that — `merge` is then the wrong command, and `finish` is still the
|
|
295
|
+
right one. Full doctrine:
|
|
296
|
+
[`references/branching.md`](plugins/agent-sync/skills/agent-sync/references/branching.md).
|
|
260
297
|
|
|
261
298
|
**The lease is not the claim.** The lease says who holds the task *now* and expires; the
|
|
262
299
|
durable claim is the tag in git (`[name]`, `todo (claimed: <role>)`). `acquire` writes
|
|
@@ -279,6 +316,8 @@ that tag through and `release` clears it, so one fact keeps one home.
|
|
|
279
316
|
| `claimTags` | file → the durable claim tag `acquire`/`release` writes through |
|
|
280
317
|
| `gates` | commands the pipeline stages run as gates |
|
|
281
318
|
| `mirror` | which git files are rendered into the read-only mirror |
|
|
319
|
+
| `integrationBranch` | where work lands and the only branch a claim is written on (default: the repo's own) |
|
|
320
|
+
| `mergeLog` | `file` and `retentionDays` for the merge log (default `docs/MERGES.md`, 7) |
|
|
282
321
|
|
|
283
322
|
`.env.agent-sync` — gitignored, mode 600:
|
|
284
323
|
|
|
@@ -405,6 +444,7 @@ agent loads on their own trigger rather than by default:
|
|
|
405
444
|
| [`two-sources.md`](plugins/agent-sync/skills/agent-sync/references/two-sources.md) | before the first reconcile, or when deciding where a document belongs |
|
|
406
445
|
| [`earned-rules.md`](plugins/agent-sync/skills/agent-sync/references/earned-rules.md) | why identity is resolved the way it is, and why `finish` exists — the two failures that produced both |
|
|
407
446
|
| [`roadmap.md`](plugins/agent-sync/skills/agent-sync/references/roadmap.md) | configuring `claimTags`, taking or closing a task, or re-planning a board |
|
|
447
|
+
| [`branching.md`](plugins/agent-sync/skills/agent-sync/references/branching.md) | starting work that will produce commits, merging a branch, or asking what landed while you were away |
|
|
408
448
|
|
|
409
449
|
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Everyone
|
|
410
450
|
taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
@@ -416,4 +456,4 @@ other agents with one command.
|
|
|
416
456
|
|
|
417
457
|
## License
|
|
418
458
|
|
|
419
|
-
MIT ©
|
|
459
|
+
MIT © ssheleg
|
package/agent-sync.example.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://raw.githubusercontent.com/
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/ssheleg/agent-sync/main/agent-sync.schema.json",
|
|
3
3
|
"backend": "outline",
|
|
4
4
|
"leaseTtlSeconds": 2700,
|
|
5
5
|
"renewIntervalSeconds": 300,
|
|
@@ -36,5 +36,10 @@
|
|
|
36
36
|
"docs/ROADMAP.md"
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
|
-
"leaseBackend": "git"
|
|
39
|
+
"leaseBackend": "git",
|
|
40
|
+
"integrationBranch": "main",
|
|
41
|
+
"mergeLog": {
|
|
42
|
+
"file": "docs/MERGES.md",
|
|
43
|
+
"retentionDays": 7
|
|
44
|
+
}
|
|
40
45
|
}
|
package/agent-sync.schema.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://raw.githubusercontent.com/
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/ssheleg/agent-sync/main/agent-sync.schema.json",
|
|
4
4
|
"title": "agent-sync project configuration",
|
|
5
5
|
"description": "Shape only. Identity (instance URL, token, container id) lives in .env.agent-sync and is never committed.",
|
|
6
6
|
"type": "object",
|
|
@@ -139,6 +139,26 @@
|
|
|
139
139
|
"minimum": 0,
|
|
140
140
|
"default": 3.0,
|
|
141
141
|
"description": "Unused by the git and local backends; retained for adapters that must wait for writes to become visible."
|
|
142
|
+
},
|
|
143
|
+
"integrationBranch": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "The branch work lands on. Claims are written through only there; on any other branch the holder lives in the coordination plane until `merge`. Default: the repository's own default branch."
|
|
146
|
+
},
|
|
147
|
+
"mergeLog": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"additionalProperties": false,
|
|
150
|
+
"description": "The record of what landed on the integration branch, written by `merge`.",
|
|
151
|
+
"properties": {
|
|
152
|
+
"file": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"description": "Path to the log, relative to the repository root. Default docs/MERGES.md."
|
|
155
|
+
},
|
|
156
|
+
"retentionDays": {
|
|
157
|
+
"type": "integer",
|
|
158
|
+
"minimum": 1,
|
|
159
|
+
"description": "How many days keep full detail; older entries are compacted to one line on the next write. Default 7."
|
|
160
|
+
}
|
|
161
|
+
}
|
|
142
162
|
}
|
|
143
163
|
}
|
|
144
164
|
}
|
package/bin/agent-sync.js
CHANGED
|
@@ -15,7 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const os = require('os');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
|
|
18
|
-
const REPO = '
|
|
18
|
+
const REPO = 'ssheleg/agent-sync';
|
|
19
19
|
const NAME = 'agent-sync';
|
|
20
20
|
const SHADOW = path.join(os.homedir(), '.claude', 'skills', NAME);
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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"
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"knowledge-base",
|
|
31
31
|
"task-pipeline"
|
|
32
32
|
],
|
|
33
|
-
"homepage": "https://github.com/
|
|
33
|
+
"homepage": "https://github.com/ssheleg/agent-sync",
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/
|
|
36
|
+
"url": "git+https://github.com/ssheleg/agent-sync.git"
|
|
37
37
|
},
|
|
38
38
|
"bugs": {
|
|
39
|
-
"url": "https://github.com/
|
|
39
|
+
"url": "https://github.com/ssheleg/agent-sync/issues"
|
|
40
40
|
},
|
|
41
|
-
"author": "
|
|
41
|
+
"author": "ssheleg",
|
|
42
42
|
"license": "MIT",
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=18"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Coordination layer for multi-agent repositories \u2014 leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
|
|
5
5
|
"author": {
|
|
6
|
-
"name": "
|
|
6
|
+
"name": "ssheleg"
|
|
7
7
|
},
|
|
8
|
-
"homepage": "https://github.com/
|
|
9
|
-
"repository": "https://github.com/
|
|
8
|
+
"homepage": "https://github.com/ssheleg/agent-sync",
|
|
9
|
+
"repository": "https://github.com/ssheleg/agent-sync",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"keywords": [
|
|
12
12
|
"multi-agent",
|
|
@@ -4,8 +4,8 @@ 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.
|
|
8
|
-
author:
|
|
7
|
+
version: "1.4.1"
|
|
8
|
+
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# agent-sync — one project, many agents, no collisions
|
|
@@ -37,6 +37,16 @@ what was there — one row, one cell, refused on ambiguity, `git diff` empty aft
|
|
|
37
37
|
round-trip. **Read `references/roadmap.md`** before configuring `claimTags` or closing a
|
|
38
38
|
task; closing is a statement about the work and stays yours.
|
|
39
39
|
|
|
40
|
+
**Work on a branch; the integration branch is somebody else's stable base.** `acquire`
|
|
41
|
+
writes the claim through **only** there; on any other branch the holder stays in the
|
|
42
|
+
coordination plane, where `status` shows it to every agent without anyone fetching your
|
|
43
|
+
branch. Committed to a branch, a claim is invisible until the merge and turns the shared
|
|
44
|
+
roadmap into a file two branches both edit. Land work with `merge`: conflicts computed by
|
|
45
|
+
`git merge-tree` **before anything is touched**, named and refused if any, the merge
|
|
46
|
+
recorded in `docs/MERGES.md` (recent days in full, older compacted on write), the lease
|
|
47
|
+
released. `merges` tells the next agent what landed while it was away. **Read
|
|
48
|
+
`references/branching.md`** before merging.
|
|
49
|
+
|
|
40
50
|
**3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
|
|
41
51
|
`guard` yourself and record the run as `ungated`. Do not describe a project as protected
|
|
42
52
|
when it is not.
|
|
@@ -60,16 +70,12 @@ setup → generate the snapshot that describes this project's wiring
|
|
|
60
70
|
check → validate the whole thing; non-zero if it is not healthy
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
**`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
mistake here; a missing, hand-edited or stale snapshot; **a snapshot no agent instruction
|
|
70
|
-
file links**, because agents that cannot find it will infer the pipeline instead; and a
|
|
71
|
-
register with no as-built baseline. Every one of those failed for real during this tool's
|
|
72
|
-
own adoption.
|
|
73
|
+
**`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy on
|
|
74
|
+
a rule that protects nothing (a register, guard glob, claim pattern, gate or mirror source
|
|
75
|
+
pointing at what is not there), on missing credentials, on an env file **tracked by git** —
|
|
76
|
+
the one unrecoverable mistake here — on a stale or unlinked snapshot, and on a register
|
|
77
|
+
with no baseline. It names each one; every one failed for real during this tool's own
|
|
78
|
+
adoption.
|
|
73
79
|
|
|
74
80
|
Run `check` after adopting, after changing the config, and in CI.
|
|
75
81
|
|
|
@@ -88,11 +94,10 @@ then proposes a config. It writes nothing.
|
|
|
88
94
|
python3 "$SKILL_DIR/scripts/agent_sync.py" adopt
|
|
89
95
|
```
|
|
90
96
|
|
|
91
|
-
Confirm the registers and guarded files with the operator
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
parent repository.
|
|
97
|
+
Confirm the registers and guarded files with the operator first: a register pointed at the
|
|
98
|
+
wrong file makes every later check confidently wrong, and a guarded list that misses a
|
|
99
|
+
shared file leaves the one place collisions happen unprotected. In a submodule it declares
|
|
100
|
+
no registers: decisions belong to the parent repository.
|
|
96
101
|
|
|
97
102
|
Then: `init` → paste the approved config → `reconcile --set-baseline` → `setup` →
|
|
98
103
|
commit the snapshot and link it from the project's agent instructions.
|
|
@@ -157,12 +162,9 @@ ONE next action.
|
|
|
157
162
|
An agent that skips this block will re-derive work someone else is doing and act on a
|
|
158
163
|
dependency state that changed an hour ago.
|
|
159
164
|
|
|
160
|
-
What else `status` decides
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Missing credentials are not an error, they are a smaller mode.
|
|
164
|
-
- `task-pipeline` absent → it prints the install line and stops. Do not improvise a
|
|
165
|
-
substitute flow; without those stages there is nothing to bind to.
|
|
165
|
+
What else `status` decides: no credentials → degraded mode, reported, and it continues (a
|
|
166
|
+
smaller mode, not an error); `task-pipeline` absent → it prints the install line and stops.
|
|
167
|
+
Do not improvise a substitute flow — without those stages there is nothing to bind to.
|
|
166
168
|
|
|
167
169
|
```bash
|
|
168
170
|
npx sshlg-skills install
|
|
@@ -189,7 +191,8 @@ npx sshlg-skills install
|
|
|
189
191
|
| `whoami` | Print this run's id and its held leases |
|
|
190
192
|
| `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
|
|
191
193
|
| `adopt` | Inspect an existing project and **propose** a config — writes nothing |
|
|
192
|
-
| `
|
|
194
|
+
| `merge` | Land this branch: conflicts checked **before** anything is touched, merge log written, lease released. `--key`, `--summary`, `--dry-run`, `--push` |
|
|
195
|
+
| `merges` | What landed while you were on your branch. `--all` includes the compacted tail |
|
|
193
196
|
| `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
|
|
194
197
|
| `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 |
|
|
195
198
|
| `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 |
|
|
@@ -209,12 +212,12 @@ guarded, and `release` takes a lease the caller never had.
|
|
|
209
212
|
The order is: `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · **the session that started this shell** ·
|
|
210
213
|
shared.
|
|
211
214
|
|
|
212
|
-
The third
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
The third matters because a plain shell command has no session id and a hook does. So
|
|
216
|
+
`SessionStart` stamps `.agent-sync/sessions/<CLI pid>` with the session it knows, and a later
|
|
217
|
+
command finds itself by walking its own process ancestry to a stamped pid — exact, and
|
|
218
|
+
deliberately not command-line parsing: the throwaway shell every tool call runs in carries
|
|
219
|
+
claude paths in its argv, so every heuristic aimed at the binary matched it instead. Stamps
|
|
220
|
+
are removed when their process is gone.
|
|
218
221
|
|
|
219
222
|
When none of the four can be established the run says so — *"this identity is shared with any other
|
|
220
223
|
session in this checkout"* — rather than presenting a shared entry as separation.
|
|
@@ -278,14 +281,13 @@ answer different questions. **The gap between them is the finding**, not a defec
|
|
|
278
281
|
|
|
279
282
|
The duty runs at both ends of every task:
|
|
280
283
|
|
|
281
|
-
- **Before starting** (docs-study stage) — `reconcile`, then read both sides for the
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
has left the next agent a divergence to find the hard way.
|
|
284
|
+
- **Before starting** (docs-study stage) — `reconcile`, then read both sides for the area
|
|
285
|
+
you are about to touch, and resolve each divergence: the git doc is stale, the as-built
|
|
286
|
+
record is wrong, or they genuinely disagree and that is a decision. Building on an
|
|
287
|
+
unresolved divergence is writing code against a system that does not exist.
|
|
288
|
+
- **After finishing** (docs stage) — `record` what you built, update the git documents
|
|
289
|
+
that state intent, then `reconcile` again. A task that updated one side leaves the next
|
|
290
|
+
agent a divergence to find the hard way.
|
|
289
291
|
|
|
290
292
|
`reconcile` is mechanical and says so: it compares ids, commits and presence, and refuses
|
|
291
293
|
to judge whether the built thing matches the document. That reading is yours.
|
|
@@ -302,17 +304,12 @@ which side a document belongs on.
|
|
|
302
304
|
This skill supplies stages; it does not define them. Stage names are
|
|
303
305
|
`task-pipeline`'s own.
|
|
304
306
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
| 4 Plan | Register file ownership for the plan's parallel groups |
|
|
312
|
-
| 5 Dev | Lease renews itself; own the submodule-commit → parent-gitlink bump |
|
|
313
|
-
| 6 Tests · 7 Lint · 8 Post-deploy | `journal` each gate result |
|
|
314
|
-
| 9 Docs + wiki | The main write point — `record` what was built, update the git docs, `signal` the dependency flips, `reconcile` again, then `board` |
|
|
315
|
-
| 10 Acceptance | `release` every lease, write the durable claim tag through to done |
|
|
307
|
+
Four of the eleven stages carry a rule the others do not, and each is about ordering:
|
|
308
|
+
**0** `acquire` before the brief is committed; **1** `reconcile` and resolve every
|
|
309
|
+
divergence before writing code; **3** `reserve` every id before it reaches git; **9**
|
|
310
|
+
`record`, `signal`, `reconcile`, `board` — the main write point. **10** ends the run:
|
|
311
|
+
`merge` if the work is on a branch, otherwise `release` every lease by hand. Full table
|
|
312
|
+
with the reasoning per stage: `references/pipeline-binding.md`.
|
|
316
313
|
|
|
317
314
|
**Read `references/pipeline-binding.md` when wiring `pipeline.json`** — it holds the
|
|
318
315
|
`skills[]` entries and the gate expressions.
|
|
@@ -401,6 +398,7 @@ Each file is loaded on its own trigger, not by default.
|
|
|
401
398
|
| `references/hooks.md` | installing, debugging or removing the Claude Code hooks |
|
|
402
399
|
| `references/two-sources.md` | before the first reconcile, or when deciding where a document belongs |
|
|
403
400
|
| `references/roadmap.md` | configuring `claimTags`, taking or closing a task, or re-planning a board |
|
|
401
|
+
| `references/branching.md` | starting work that will produce commits, merging a branch, or asking what landed while you were away |
|
|
404
402
|
|
|
405
403
|
If this copy arrived without `references/`, fetch them from
|
|
406
|
-
`https://raw.githubusercontent.com/
|
|
404
|
+
`https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Branch discipline, merging, and the merge log
|
|
2
|
+
|
|
3
|
+
**Read this when** starting work that will produce commits, merging a branch, or deciding
|
|
4
|
+
where a claim should be written.
|
|
5
|
+
|
|
6
|
+
## The rule
|
|
7
|
+
|
|
8
|
+
> **Work happens on a branch. The integration branch is somebody else's stable base.**
|
|
9
|
+
> Nothing about work in flight is committed there — the holder lives in the coordination
|
|
10
|
+
> plane until the work lands.
|
|
11
|
+
|
|
12
|
+
Three failures make this a rule rather than a preference, and all three happened here:
|
|
13
|
+
|
|
14
|
+
| What breaks | Why |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Two agents edit the roadmap to mark a claim | The one file that exists to prevent collisions becomes the file every branch touches, so every merge conflicts on it |
|
|
17
|
+
| A claim committed to a branch tells nobody | It is invisible on the integration branch until the merge — exactly when it stops being useful |
|
|
18
|
+
| Two agents commit straight to the integration branch | The second push is rejected, the work is already duplicated, and the repository has two versions of one change |
|
|
19
|
+
|
|
20
|
+
## Where a claim lives
|
|
21
|
+
|
|
22
|
+
`acquire` writes the claim through to the roadmap **only when the run is on the
|
|
23
|
+
integration branch**. On any other branch it says so and writes nothing:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
claim for `ASC-072` left in the coordination plane — this run is on 'feature/x',
|
|
27
|
+
not main. `status` shows the holder to every agent; `merge` records the outcome.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That is not a downgrade. The plane is where the claim is *more* visible: `status` lists
|
|
31
|
+
every other run's live holdings, from any checkout and any repository, and neither agent
|
|
32
|
+
has to fetch the other's branch to see it. Git keeps what must survive — the merge, and
|
|
33
|
+
the log entry naming it.
|
|
34
|
+
|
|
35
|
+
The integration branch is `integrationBranch` in the config, or the repository's own
|
|
36
|
+
default branch when that is unset. It is asked of the repository, never assumed.
|
|
37
|
+
|
|
38
|
+
## Landing the work
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merge --key ASC-072 --summary "what landed"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In order, and every check before anything is touched:
|
|
45
|
+
|
|
46
|
+
1. **Refuses a detached HEAD, the integration branch itself, and a dirty tree.** A merge
|
|
47
|
+
cannot tell uncommitted work from the branch's own commits.
|
|
48
|
+
2. **Fetches the integration branch** and reports how far it moved since the branch
|
|
49
|
+
started — the number that predicts how stale the work is.
|
|
50
|
+
3. **Computes conflicts with `git merge-tree`**, in memory. A merge that starts and then
|
|
51
|
+
aborts leaves the operator in a repository they did not ask for. On conflict it names
|
|
52
|
+
the files, changes nothing, and exits non-zero: resolve in your own branch, where the
|
|
53
|
+
resolution is reviewable, and run it again.
|
|
54
|
+
4. **Lists every other run's live lease.** Their work is not in this diff; if it touches
|
|
55
|
+
the same files, they merge into what is about to land.
|
|
56
|
+
5. Merges `--no-ff`, so the branch stays visible in history.
|
|
57
|
+
6. **Writes the merge log** and commits it.
|
|
58
|
+
7. **Releases every lease this run holds.** A run that ends holding one blocks the next
|
|
59
|
+
agent for the whole TTL.
|
|
60
|
+
|
|
61
|
+
`--dry-run` stops after step 4. `--push` pushes the integration branch afterwards;
|
|
62
|
+
without it, `finish` is the next call — it checks every repository, not just this one.
|
|
63
|
+
|
|
64
|
+
## The merge log
|
|
65
|
+
|
|
66
|
+
`docs/MERGES.md` by default, `mergeLog.file` to move it, `mergeLog.retentionDays` to
|
|
67
|
+
change the window (7 days).
|
|
68
|
+
|
|
69
|
+
It answers one question an agent returning from a branch cannot answer from git alone
|
|
70
|
+
without reading every commit: **what landed while I was away, and was any of it near my
|
|
71
|
+
work.** `git log` has the same facts and none of the summary; a changelog has the summary
|
|
72
|
+
and only for released work.
|
|
73
|
+
|
|
74
|
+
```markdown
|
|
75
|
+
### 2026-07-30T16:42:11Z · `ASC-072` · feature/rename → main · `ec414cd`
|
|
76
|
+
- run: r-4f9c2
|
|
77
|
+
- files: 8 (8 files changed, 120 insertions(+), 31 deletions(-))
|
|
78
|
+
- conflicts: none
|
|
79
|
+
- summary: every address moved to the new owner
|
|
80
|
+
|
|
81
|
+
## Compacted
|
|
82
|
+
|
|
83
|
+
- 2026-07-21 · `ASC-060` · fix/lease-visibility → main · `9817f19` · the guard denied the holder
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Compaction happens on write.** Every `merge` re-reads the file, keeps entries inside the
|
|
87
|
+
window in full, and folds everything older into one line each under `## Compacted`. No
|
|
88
|
+
cron, no second command, and no log that grows until people stop reading it — which is the
|
|
89
|
+
same as not having one.
|
|
90
|
+
|
|
91
|
+
Read it at the start of a task:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merges # recent detail
|
|
95
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merges --all # including the compacted tail
|
|
96
|
+
python3 "$SKILL_DIR/scripts/agent_sync.py" merges --compact # force the pass now
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## What this does not do
|
|
100
|
+
|
|
101
|
+
- **No pull request.** This is the flow for agents that share one repository and merge
|
|
102
|
+
their own work. A project that reviews through pull requests keeps doing that; `merge`
|
|
103
|
+
is then the wrong command, and `finish` is still the right one.
|
|
104
|
+
- **It does not make a branch safe to skip a lease.** Two agents on two branches editing
|
|
105
|
+
one file still collide — later, at merge time, with more work already spent. `acquire`
|
|
106
|
+
first; the branch is where the work goes, not what decides who does it.
|
|
107
|
+
- **It does not rebase for you.** A conflicting merge is refused with the file list; the
|
|
108
|
+
resolution belongs in the branch, in a commit somebody can read.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
| **7 Lint + deploy** | `journal` per gate | — |
|
|
20
20
|
| **8 Post-deploy** | `journal` | — |
|
|
21
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
|
|
22
|
+
| **10 Acceptance** | `merge` when the work is on a branch — it records the merge and releases; otherwise `release` every lease and write the claim tag through | A run that ends without releasing looks alive until its TTL expires |
|
|
23
23
|
|
|
24
24
|
## pipeline.json
|
|
25
25
|
|
|
Binary file
|
|
@@ -32,12 +32,15 @@ from datetime import datetime, timezone
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Any
|
|
34
34
|
|
|
35
|
-
VERSION = "1.
|
|
35
|
+
VERSION = "1.4.1"
|
|
36
36
|
|
|
37
37
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
38
38
|
ENV_FILE = Path(".env.agent-sync")
|
|
39
39
|
STATE_DIR = Path(".agent-sync")
|
|
40
40
|
GENERATED_MARKER = "<!-- agent-sync:generated"
|
|
41
|
+
MERGE_LOG_MARKER = "<!-- agent-sync:merge-log -->"
|
|
42
|
+
DEFAULT_MERGE_LOG = "docs/MERGES.md"
|
|
43
|
+
DEFAULT_MERGE_RETENTION = 7
|
|
41
44
|
|
|
42
45
|
# What a won lease is actually worth, in one place. Six surfaces used to phrase this
|
|
43
46
|
# independently and two of them named the knowledge base as the authority — a role it
|
|
@@ -119,6 +122,53 @@ def head_sha() -> str:
|
|
|
119
122
|
return git("rev-parse", "--short", "HEAD") or "unknown"
|
|
120
123
|
|
|
121
124
|
|
|
125
|
+
def current_branch() -> str:
|
|
126
|
+
return git("rev-parse", "--abbrev-ref", "HEAD") or ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def default_branch() -> str:
|
|
130
|
+
"""The branch everything integrates into. Asked of the repository, never assumed."""
|
|
131
|
+
ref = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
132
|
+
if ref:
|
|
133
|
+
return ref.rsplit("/", 1)[-1]
|
|
134
|
+
for cand in ("main", "master"):
|
|
135
|
+
if git("rev-parse", "--verify", "--quiet", cand):
|
|
136
|
+
return cand
|
|
137
|
+
return "main"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def merge_conflicts(target: str, branch: str) -> list[str]:
|
|
141
|
+
"""Files that would conflict, decided WITHOUT touching the working tree.
|
|
142
|
+
|
|
143
|
+
A merge that starts and then aborts still leaves the operator in a repository they
|
|
144
|
+
did not expect. `git merge-tree` answers the same question in memory. Modern git
|
|
145
|
+
(2.38+) takes --write-tree and reports conflicted paths; older git prints a diff
|
|
146
|
+
where a conflict shows up as marker lines, so both forms are read here.
|
|
147
|
+
"""
|
|
148
|
+
r = subprocess.run(["git", "merge-tree", "--write-tree", "--name-only", target, branch],
|
|
149
|
+
capture_output=True, text=True)
|
|
150
|
+
if r.returncode == 0:
|
|
151
|
+
return []
|
|
152
|
+
if r.returncode == 1:
|
|
153
|
+
# Layout: tree oid, the conflicted paths, a blank line, then git's own prose
|
|
154
|
+
# ("Auto-merging …", "CONFLICT (content) …"). Only the paths are the answer;
|
|
155
|
+
# printing the prose as if it were a filename makes the report untrustworthy.
|
|
156
|
+
paths: list[str] = []
|
|
157
|
+
for line in r.stdout.splitlines()[1:]:
|
|
158
|
+
if not line.strip():
|
|
159
|
+
break
|
|
160
|
+
paths.append(line)
|
|
161
|
+
return paths
|
|
162
|
+
|
|
163
|
+
base = git("merge-base", target, branch)
|
|
164
|
+
if not base:
|
|
165
|
+
return ["(cannot determine a merge base — unrelated histories)"]
|
|
166
|
+
old = subprocess.run(["git", "merge-tree", base, target, branch],
|
|
167
|
+
capture_output=True, text=True)
|
|
168
|
+
return ["(conflicting hunks — this git is too old to name the files)"] \
|
|
169
|
+
if "<<<<<<<" in old.stdout else []
|
|
170
|
+
|
|
171
|
+
|
|
122
172
|
def repo_name() -> str:
|
|
123
173
|
url = git("config", "--get", "remote.origin.url")
|
|
124
174
|
if url:
|
|
@@ -1170,6 +1220,98 @@ class Sync:
|
|
|
1170
1220
|
raw = raw[:-1]
|
|
1171
1221
|
return raw[1:].split("|")
|
|
1172
1222
|
|
|
1223
|
+
# -- branch discipline -------------------------------------------------
|
|
1224
|
+
|
|
1225
|
+
@property
|
|
1226
|
+
def integration_branch(self) -> str:
|
|
1227
|
+
"""Where work lands. Configured, or asked of the repository — never assumed."""
|
|
1228
|
+
return self.cfg.get("integrationBranch") or default_branch()
|
|
1229
|
+
|
|
1230
|
+
@property
|
|
1231
|
+
def on_integration_branch(self) -> bool:
|
|
1232
|
+
return current_branch() == self.integration_branch
|
|
1233
|
+
|
|
1234
|
+
def merge_log(self) -> tuple[Path, int]:
|
|
1235
|
+
spec = self.cfg.get("mergeLog") or {}
|
|
1236
|
+
return (self.root / (spec.get("file") or DEFAULT_MERGE_LOG),
|
|
1237
|
+
int(spec.get("retentionDays") or DEFAULT_MERGE_RETENTION))
|
|
1238
|
+
|
|
1239
|
+
def merge_log_append(self, entry: dict[str, str] | None = None) -> Path:
|
|
1240
|
+
"""Append one merge, then compact anything past the retention window.
|
|
1241
|
+
|
|
1242
|
+
Two audiences, one file. An agent that just arrived needs the last few days in
|
|
1243
|
+
enough detail to know what changed under it; nobody needs that detail from three
|
|
1244
|
+
weeks ago, and a log that only grows stops being read — which is the same as not
|
|
1245
|
+
having one. Compaction happens on write, so it needs no cron and no second command.
|
|
1246
|
+
"""
|
|
1247
|
+
path, retention = self.merge_log()
|
|
1248
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1249
|
+
detailed, compacted = self._read_merge_log(path)
|
|
1250
|
+
|
|
1251
|
+
if entry is not None:
|
|
1252
|
+
detailed.insert(0, entry)
|
|
1253
|
+
cutoff = time.time() - retention * 86400
|
|
1254
|
+
keep, aged = [], []
|
|
1255
|
+
for e in detailed:
|
|
1256
|
+
(keep if parse_iso(e.get("ts", "")) >= cutoff else aged).append(e)
|
|
1257
|
+
compacted = [self._one_line(e) for e in aged] + compacted
|
|
1258
|
+
|
|
1259
|
+
path.write_text(self._render_merge_log(keep, compacted, retention))
|
|
1260
|
+
return path
|
|
1261
|
+
|
|
1262
|
+
@staticmethod
|
|
1263
|
+
def _one_line(e: dict[str, str]) -> str:
|
|
1264
|
+
return (f"- {e.get('ts', '')[:10]} · `{e.get('key', '—')}` · {e.get('branch', '?')} → "
|
|
1265
|
+
f"{e.get('target', '?')} · `{e.get('sha', '?')}` · {e.get('summary', '')}".rstrip())
|
|
1266
|
+
|
|
1267
|
+
@staticmethod
|
|
1268
|
+
def _read_merge_log(path: Path) -> tuple[list[dict[str, str]], list[str]]:
|
|
1269
|
+
if not path.exists():
|
|
1270
|
+
return [], []
|
|
1271
|
+
text = path.read_text()
|
|
1272
|
+
body, _, tail = text.partition("\n## Compacted\n")
|
|
1273
|
+
compacted = [l for l in tail.splitlines() if l.startswith("- ")]
|
|
1274
|
+
entries: list[dict[str, str]] = []
|
|
1275
|
+
for block in body.split("\n### ")[1:]:
|
|
1276
|
+
lines = block.splitlines()
|
|
1277
|
+
head = lines[0]
|
|
1278
|
+
e = {"ts": "", "key": "—", "branch": "?", "target": "?", "sha": "?",
|
|
1279
|
+
"run": "?", "files": "?", "conflicts": "none", "summary": ""}
|
|
1280
|
+
parts = [p.strip() for p in head.split("·")]
|
|
1281
|
+
if parts:
|
|
1282
|
+
e["ts"] = parts[0]
|
|
1283
|
+
if len(parts) > 1:
|
|
1284
|
+
e["key"] = parts[1].strip("`")
|
|
1285
|
+
if len(parts) > 2 and "→" in parts[2]:
|
|
1286
|
+
e["branch"], _, e["target"] = (x.strip() for x in parts[2].partition("→"))
|
|
1287
|
+
if len(parts) > 3:
|
|
1288
|
+
e["sha"] = parts[3].strip("`")
|
|
1289
|
+
for l in lines[1:]:
|
|
1290
|
+
for field in ("run", "files", "conflicts", "summary"):
|
|
1291
|
+
if l.startswith(f"- {field}: "):
|
|
1292
|
+
e[field] = l.split(": ", 1)[1]
|
|
1293
|
+
entries.append(e)
|
|
1294
|
+
return entries, compacted
|
|
1295
|
+
|
|
1296
|
+
@staticmethod
|
|
1297
|
+
def _render_merge_log(entries: list[dict[str, str]], compacted: list[str],
|
|
1298
|
+
retention: int) -> str:
|
|
1299
|
+
out = [MERGE_LOG_MARKER, "", "# Merge log", "",
|
|
1300
|
+
f"Written by `agent_sync.py merge`. Entries newer than {retention} days keep "
|
|
1301
|
+
"their detail; older ones are compacted to one line each on the next write. "
|
|
1302
|
+
"Read it before starting work: it is the shortest answer to *what landed while "
|
|
1303
|
+
"I was on my branch*.", ""]
|
|
1304
|
+
for e in entries:
|
|
1305
|
+
out += [f"### {e['ts']} · `{e['key']}` · {e['branch']} → {e['target']} · `{e['sha']}`",
|
|
1306
|
+
f"- run: {e['run']}",
|
|
1307
|
+
f"- files: {e['files']}",
|
|
1308
|
+
f"- conflicts: {e['conflicts']}",
|
|
1309
|
+
f"- summary: {e['summary']}", ""]
|
|
1310
|
+
# The placeholder deliberately does not start with "- ": the reader counts list
|
|
1311
|
+
# items, and an empty log that reports one compacted entry is a lie about history.
|
|
1312
|
+
out += ["## Compacted", ""] + (compacted or ["_nothing older than the window yet_"])
|
|
1313
|
+
return "\n".join(out) + "\n"
|
|
1314
|
+
|
|
1173
1315
|
def write_claim(self, key: str, holder: str | None) -> list[str]:
|
|
1174
1316
|
"""Write the claim through to git, or restore it. Surgical and reversible.
|
|
1175
1317
|
|
|
@@ -1178,6 +1320,17 @@ class Sync:
|
|
|
1178
1320
|
exists to prevent. The previous cell text is stored in the lock file, so release
|
|
1179
1321
|
restores what was there rather than an assumed default.
|
|
1180
1322
|
"""
|
|
1323
|
+
# A claim is a statement about the integration branch, so it is only written
|
|
1324
|
+
# there. Committed on a feature branch it is invisible to everyone until the
|
|
1325
|
+
# merge — and it turns the shared roadmap into a file two branches both edit,
|
|
1326
|
+
# which is a merge conflict on the one file that exists to prevent collisions.
|
|
1327
|
+
# While the work is on a branch the holder lives in the coordination plane,
|
|
1328
|
+
# where `status` and the board already read it.
|
|
1329
|
+
if holder is not None and not self.on_integration_branch:
|
|
1330
|
+
return [f"claim for `{key}` left in the coordination plane — this run is on "
|
|
1331
|
+
f"'{current_branch()}', not {self.integration_branch}. `status` shows the "
|
|
1332
|
+
f"holder to every agent; `merge` records the outcome."]
|
|
1333
|
+
|
|
1181
1334
|
notes: list[str] = []
|
|
1182
1335
|
for path, spec in self._claim_targets(key):
|
|
1183
1336
|
if spec.get("mode") != "cell":
|
|
@@ -1855,6 +2008,7 @@ def default_config(backend: str) -> dict[str, Any]:
|
|
|
1855
2008
|
"claimTags": {},
|
|
1856
2009
|
"gates": [],
|
|
1857
2010
|
"mirror": {"enabled": False, "sources": []},
|
|
2011
|
+
"mergeLog": {"file": DEFAULT_MERGE_LOG, "retentionDays": DEFAULT_MERGE_RETENTION},
|
|
1858
2012
|
}
|
|
1859
2013
|
|
|
1860
2014
|
|
|
@@ -2829,6 +2983,120 @@ def cmd_whoami(_args: argparse.Namespace) -> int:
|
|
|
2829
2983
|
return 0
|
|
2830
2984
|
|
|
2831
2985
|
|
|
2986
|
+
def cmd_merge(args: argparse.Namespace) -> int:
|
|
2987
|
+
"""Land a branch on the integration branch, and leave a record of what landed.
|
|
2988
|
+
|
|
2989
|
+
Every check here runs *before* anything is touched, because a merge that starts and
|
|
2990
|
+
then aborts leaves the operator somewhere they did not ask to be. The conflict answer
|
|
2991
|
+
comes from `git merge-tree`, which computes it in memory.
|
|
2992
|
+
"""
|
|
2993
|
+
s = Sync()
|
|
2994
|
+
branch = current_branch()
|
|
2995
|
+
target = args.into or s.integration_branch
|
|
2996
|
+
|
|
2997
|
+
if not branch or branch == "HEAD":
|
|
2998
|
+
raise Fail("detached HEAD — check out the branch you want to merge")
|
|
2999
|
+
if branch == target:
|
|
3000
|
+
raise Fail(f"already on {target} — there is no branch to merge. Work happens on a "
|
|
3001
|
+
f"branch so the integration branch stays somebody else's stable base")
|
|
3002
|
+
dirty = [l for l in (git("status", "--porcelain") or "").splitlines()
|
|
3003
|
+
if l and STATE_DIR.name not in l]
|
|
3004
|
+
if dirty:
|
|
3005
|
+
raise Fail(f"working tree is not clean ({len(dirty)} path(s)) — commit or stash first; "
|
|
3006
|
+
"a merge cannot tell your uncommitted work from the branch's")
|
|
3007
|
+
|
|
3008
|
+
git("fetch", "--quiet", "origin", target)
|
|
3009
|
+
upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
|
|
3010
|
+
behind = git("rev-list", "--count", f"{branch}..{upstream}")
|
|
3011
|
+
changed = [l for l in (git("diff", "--name-only", f"{upstream}...{branch}") or "").splitlines() if l]
|
|
3012
|
+
stat = git("diff", "--shortstat", f"{upstream}...{branch}") or "no changes"
|
|
3013
|
+
|
|
3014
|
+
print(f"{branch} → {target}")
|
|
3015
|
+
print(f" files changed : {len(changed)} ({stat.strip()})")
|
|
3016
|
+
print(f" {target} moved : {behind or '0'} commit(s) since this branch started")
|
|
3017
|
+
|
|
3018
|
+
conflicts = merge_conflicts(upstream, branch)
|
|
3019
|
+
if conflicts:
|
|
3020
|
+
print("\n✗ this merge conflicts. Nothing was touched.")
|
|
3021
|
+
for c in conflicts[:20]:
|
|
3022
|
+
print(f" · {c}")
|
|
3023
|
+
print(f"\nNEXT: rebase or merge {target} into {branch} in your own branch, resolve there,")
|
|
3024
|
+
print(" then run this again. The integration branch never carries a resolution nobody reviewed.")
|
|
3025
|
+
return 1
|
|
3026
|
+
print(" conflicts : none")
|
|
3027
|
+
|
|
3028
|
+
others = {k: v for k, v in s.all_holders().items() if v != s.rid}
|
|
3029
|
+
if others:
|
|
3030
|
+
print("\n other runs hold leases right now:")
|
|
3031
|
+
for k, v in sorted(others.items()):
|
|
3032
|
+
print(f" · {v} holds {k}")
|
|
3033
|
+
print(" Their work is not in this diff. If it touches the same files, they merge into "
|
|
3034
|
+
"what you are about to land.")
|
|
3035
|
+
|
|
3036
|
+
if args.dry_run:
|
|
3037
|
+
print("\n(dry run — nothing merged)")
|
|
3038
|
+
return 0
|
|
3039
|
+
|
|
3040
|
+
if git("checkout", target) == "" and current_branch() != target:
|
|
3041
|
+
raise Fail(f"could not check out {target}")
|
|
3042
|
+
msg = args.message or f"Merge {branch}" + (f" — {args.key}" if args.key else "")
|
|
3043
|
+
r = subprocess.run(["git", "merge", "--no-ff", "-m", msg, branch],
|
|
3044
|
+
capture_output=True, text=True)
|
|
3045
|
+
if r.returncode != 0:
|
|
3046
|
+
subprocess.run(["git", "merge", "--abort"], capture_output=True)
|
|
3047
|
+
git("checkout", branch)
|
|
3048
|
+
raise Fail(f"merge failed and was aborted; you are back on {branch}\n"
|
|
3049
|
+
f" {(r.stderr or r.stdout).strip().splitlines()[-1] if (r.stderr or r.stdout).strip() else ''}")
|
|
3050
|
+
sha = head_sha()
|
|
3051
|
+
print(f"\n✓ merged as {sha}")
|
|
3052
|
+
|
|
3053
|
+
path = s.merge_log_append({
|
|
3054
|
+
"ts": now_iso(), "key": args.key or "—", "branch": branch, "target": target,
|
|
3055
|
+
"sha": sha, "run": s.rid, "files": f"{len(changed)} ({stat.strip()})",
|
|
3056
|
+
"conflicts": "none", "summary": args.summary or "(no summary given)",
|
|
3057
|
+
})
|
|
3058
|
+
rel_log = path.relative_to(s.root)
|
|
3059
|
+
git("add", str(rel_log))
|
|
3060
|
+
subprocess.run(["git", "commit", "--quiet", "-m",
|
|
3061
|
+
f"docs(merges): record {branch} → {target}"], capture_output=True)
|
|
3062
|
+
print(f"✓ recorded in {rel_log}")
|
|
3063
|
+
|
|
3064
|
+
for key in (s.held() or []):
|
|
3065
|
+
s.release(key)
|
|
3066
|
+
print(f"✓ released {key}")
|
|
3067
|
+
|
|
3068
|
+
if args.push:
|
|
3069
|
+
out = subprocess.run(["git", "push", "origin", target], capture_output=True, text=True)
|
|
3070
|
+
print(f"✓ pushed {target}" if out.returncode == 0
|
|
3071
|
+
else f"✗ push failed: {(out.stderr or '').strip().splitlines()[-1:]}")
|
|
3072
|
+
else:
|
|
3073
|
+
print(f"\nNEXT: push {target}, or run `finish` to check every repository first.")
|
|
3074
|
+
return 0
|
|
3075
|
+
|
|
3076
|
+
|
|
3077
|
+
def cmd_merges(args: argparse.Namespace) -> int:
|
|
3078
|
+
"""What landed while you were on your branch."""
|
|
3079
|
+
s = Sync()
|
|
3080
|
+
path, retention = s.merge_log()
|
|
3081
|
+
if not path.exists():
|
|
3082
|
+
print(f"no merge log yet at {path.relative_to(s.root)} — `merge` writes one")
|
|
3083
|
+
return 0
|
|
3084
|
+
if args.compact:
|
|
3085
|
+
s.merge_log_append() # same pass the writer runs, without a new entry
|
|
3086
|
+
print("compacted anything past the retention window")
|
|
3087
|
+
entries, compacted = s._read_merge_log(path)
|
|
3088
|
+
print(f"{path.relative_to(s.root)} — {len(entries)} detailed (last {retention} days), "
|
|
3089
|
+
f"{len(compacted)} compacted\n")
|
|
3090
|
+
for e in (entries if args.all else entries[:args.limit]):
|
|
3091
|
+
print(f"{e['ts']} · {e['key']} · {e['branch']} → {e['target']} · {e['sha']}")
|
|
3092
|
+
print(f" {e['summary']}")
|
|
3093
|
+
if args.all and compacted:
|
|
3094
|
+
print("\nolder:")
|
|
3095
|
+
for line in compacted[:args.limit if not args.all else len(compacted)]:
|
|
3096
|
+
print(f" {line[2:]}")
|
|
3097
|
+
return 0
|
|
3098
|
+
|
|
3099
|
+
|
|
2832
3100
|
def build_parser() -> argparse.ArgumentParser:
|
|
2833
3101
|
p = argparse.ArgumentParser(prog="agent_sync.py", description=__doc__.splitlines()[0])
|
|
2834
3102
|
p.add_argument("--version", action="version", version=VERSION)
|
|
@@ -2849,6 +3117,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2849
3117
|
fi = sub.add_parser("finish", help="is the work finished — every repo clean, pushed and pointed at; no lease held")
|
|
2850
3118
|
fi.add_argument("--gates", action="store_true", help="also run the project's declared gate commands")
|
|
2851
3119
|
fi.set_defaults(fn=cmd_finish)
|
|
3120
|
+
mg = sub.add_parser("merge", help="land this branch on the integration branch, and record it")
|
|
3121
|
+
mg.add_argument("--into", help="integration branch (default: config, else the repo's own)")
|
|
3122
|
+
mg.add_argument("--key", help="the task id this branch delivered")
|
|
3123
|
+
mg.add_argument("--summary", help="one line for the merge log — what landed")
|
|
3124
|
+
mg.add_argument("--message", help="merge commit message")
|
|
3125
|
+
mg.add_argument("--push", action="store_true", help="push the integration branch afterwards")
|
|
3126
|
+
mg.add_argument("--dry-run", action="store_true", help="check conflicts and stop")
|
|
3127
|
+
mg.set_defaults(fn=cmd_merge)
|
|
3128
|
+
ml = sub.add_parser("merges", help="what landed while you were on your branch")
|
|
3129
|
+
ml.add_argument("--all", action="store_true", help="include the compacted tail")
|
|
3130
|
+
ml.add_argument("--limit", type=int, default=10, help="how many detailed entries to print")
|
|
3131
|
+
ml.add_argument("--compact", action="store_true", help="run the compaction pass now")
|
|
3132
|
+
ml.set_defaults(fn=cmd_merges)
|
|
2852
3133
|
sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
|
|
2853
3134
|
sc.add_argument("--docs-dir", action="store_true", help="put the register under docs/ even if it does not exist yet")
|
|
2854
3135
|
sc.add_argument("--full", action="store_true",
|
|
Binary file
|
|
Binary file
|