@ssheleg/agent-sync 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,121 @@
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.0 — 2026-07-30
7
+
8
+ ### Work happens on a branch, and the integration branch stays somebody else's stable base
9
+
10
+ Two agents renamed this repository the same minute yesterday, both committing straight to
11
+ `main`. The second push was rejected — after the work was already duplicated. Nothing in
12
+ the tool said where work should happen, so both did the obvious thing.
13
+
14
+ - **`acquire` writes the claim through only on the integration branch.** On any other
15
+ branch it says so and writes nothing: a claim committed to a branch is invisible to
16
+ every other agent until the merge, and it turns the shared roadmap into a file two
17
+ branches both edit — a conflict on the one file that exists to prevent collisions. The
18
+ holder lives in the coordination plane, where `status` already shows it to everyone
19
+ without anyone fetching a branch.
20
+ - **`integrationBranch`** in the config, or the repository's own default branch when unset.
21
+ Asked of the repository, never assumed.
22
+
23
+ ### `merge` — land a branch, with every check before anything is touched
24
+
25
+ `merge` refuses a detached HEAD, the integration branch itself and a dirty tree; fetches
26
+ the target and reports how far it moved; computes conflicts with **`git merge-tree`, in
27
+ memory**; lists every other run's live lease; merges `--no-ff`; records the merge; and
28
+ releases every lease this run holds. On conflict it names the files, changes nothing and
29
+ exits non-zero — a merge that starts and aborts leaves the operator in a repository they
30
+ did not ask for, and a resolution nobody reviewed does not belong on the integration
31
+ branch. `--dry-run` stops after the checks, `--push` pushes afterwards.
32
+
33
+ ### `merges` — what landed while you were on your branch
34
+
35
+ A merge log at `docs/MERGES.md` (`mergeLog.file`), written by `merge`. It answers what
36
+ `git log` cannot without reading every commit and what a changelog only covers once
37
+ released: *what landed while I was away, and was any of it near my work.*
38
+
39
+ **Compaction happens on write.** Entries inside `mergeLog.retentionDays` (7) keep their
40
+ detail; older ones fold to one line each on the next merge. No cron, no second command,
41
+ and no log that grows until people stop reading it — which is the same as not having one.
42
+
43
+ ### Added
44
+ - `test/validate.py` exercises both rules against throwaway repositories: `acquire` on a
45
+ feature branch must leave the roadmap untouched **and** say where the claim lives, while
46
+ on the integration branch it must still write through; a conflicting `merge` must exit
47
+ non-zero, name the conflicting file, leave the checkout on the original branch with a
48
+ clean tree, and write **no** log entry for a merge that did not happen. Both have
49
+ self-test fixtures that remove the guard and confirm the check goes red.
50
+ - `references/branching.md`, and the doctrine in the skill body.
51
+
52
+ ## 1.3.9 — 2026-07-30
53
+
54
+ ### Changed
55
+ - `license: MIT` added to the `marketplace.json` plugin entry. The front matter
56
+ already declared it — this repo was the only one in the family that did — but
57
+ the plugin listing, which is what a user reads before installing, did not.
58
+
59
+
60
+ ## 1.3.8 — 2026-07-30
61
+
62
+ The 1.3.6 move was performed twice, by two agents in this repository within the same
63
+ minute, and neither held a lease on it. Both renames were correct and nearly identical —
64
+ the duplicated effort is the cost, and this project exists to make it visible. This
65
+ release carries the two things only one of them had, and it is the release that moves the
66
+ npm package page: 1.3.6 and 1.3.7 were tagged and never published.
67
+
68
+ ### Changed
69
+ - **`LICENSE` and the README footer read `ssheleg`.** The rename moved every address and
70
+ every manifest identity, and left the copyright line naming the previous owner — the one
71
+ statement in this repository with legal weight.
72
+
73
+ ### Added
74
+ - **`test/validate.py` fails a half-finished rename.** It derives the canonical slug from
75
+ `package.json` and rejects any other owner of this repository name anywhere in the tree:
76
+ in a URL, in a `github:` install argument, in a `marketplace add` line, or as a bare
77
+ quoted slug. That last shape is what the installers actually clone, and the first draft
78
+ of the check missed it — `bin/agent-sync.js` and `install.sh` both passed while still
79
+ pointing at the old owner. Verified red against the pre-rename tree; the self-test
80
+ fixture assembles its slug from parts, because written whole it makes the validator the
81
+ file that fails the check.
82
+
83
+ ## 1.3.7 — 2026-07-30
84
+
85
+ ### Fixed
86
+ - **The 1.3.6 note below claimed `raw.githubusercontent.com` does not follow a
87
+ repository transfer. It does** — the old owner's raw path returns 200 with the
88
+ current file, no redirect involved, because GitHub resolves a transferred
89
+ repository by identity on every surface (git, web and raw alike). Measured
90
+ after the move, which is what the claim should have been before it was
91
+ written. The paragraph is corrected in place so no install ships the wrong
92
+ fact; the reason to update the URLs is unchanged and stated accurately there.
93
+
94
+
95
+ ## 1.3.6 — 2026-07-30
96
+
97
+ ### Changed
98
+
99
+ The repository moved from `appvillis-com/agent-sync` to **`ssheleg/agent-sync`**,
100
+ joining the rest of the family under one owner. GitHub keeps serving the old path
101
+ on every surface, so nothing breaks today — but that only holds while the
102
+ `appvillis-com` name is never re-registered, and a reference that depends on
103
+ somebody else not taking a name is worth one commit to remove.
104
+
105
+ - **Install paths** — `install.sh`, `bin/agent-sync.js` (the `npx github:…`
106
+ fallback), and the README's npx and `claude plugin marketplace add` commands.
107
+ - **Identity** — `package.json` (homepage, repository, bugs, author),
108
+ `.claude-plugin/marketplace.json` (owner, homepage, repository, plugin author),
109
+ `plugins/agent-sync/.claude-plugin/plugin.json`, and the `author` in the skill's
110
+ front matter: `appvillis-com` → `ssheleg`.
111
+ - **Raw URLs** — `agent-sync.schema.json` `$id`, the `$schema` in
112
+ `agent-sync.example.json`, and the reference-loading fallback URL in `SKILL.md`.
113
+ A `$id` is an identifier as much as a location, so it should name the repository
114
+ that actually holds the schema.
115
+ - Cursor rule, code of conduct and the security-advisory link in the issue-template
116
+ config.
117
+
118
+ The changelog entries below and `docs/superpowers/specs/` keep the old path on
119
+ purpose: they record where this was published at the time.
120
+
6
121
  ## 1.3.5 — 2026-07-29
7
122
 
8
123
  ### The two rules this plugin enforces are now stated, with the failures that taught them
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Appvillis
3
+ Copyright (c) 2026 ssheleg
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # agent-sync
2
2
 
3
- [![CI](https://github.com/appvillis-com/agent-sync/actions/workflows/validate.yml/badge.svg)](https://github.com/appvillis-com/agent-sync/actions/workflows/validate.yml)
3
+ [![CI](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml/badge.svg)](https://github.com/ssheleg/agent-sync/actions/workflows/validate.yml)
4
4
  [![npm](https://img.shields.io/npm/v/%40ssheleg%2Fagent-sync)](https://www.npmjs.com/package/@ssheleg/agent-sync)
5
5
  [![license](https://img.shields.io/badge/license-MIT-blue)](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:appvillis-com/agent-sync install
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 appvillis-com/agent-sync && claude plugin install agent-sync@agent-sync
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 → do the workrelease
256
+ acquire → work on a branch merge
255
257
  ```
256
258
 
257
- Never skip `release`, including when the work failed. An abandoned lease blocks the task
258
- until its TTL expires, and the next agent cannot tell "in progress" from "crashed an hour
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 © Appvillis
459
+ MIT © ssheleg
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://raw.githubusercontent.com/appvillis-com/agent-sync/main/agent-sync.schema.json",
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "https://raw.githubusercontent.com/appvillis-com/agent-sync/main/agent-sync.schema.json",
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 = 'appvillis-com/agent-sync';
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.5",
3
+ "version": "1.4.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"
@@ -30,15 +30,15 @@
30
30
  "knowledge-base",
31
31
  "task-pipeline"
32
32
  ],
33
- "homepage": "https://github.com/appvillis-com/agent-sync",
33
+ "homepage": "https://github.com/ssheleg/agent-sync",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/appvillis-com/agent-sync.git"
36
+ "url": "git+https://github.com/ssheleg/agent-sync.git"
37
37
  },
38
38
  "bugs": {
39
- "url": "https://github.com/appvillis-com/agent-sync/issues"
39
+ "url": "https://github.com/ssheleg/agent-sync/issues"
40
40
  },
41
- "author": "appvillis-com",
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.5",
3
+ "version": "1.4.0",
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": "appvillis-com"
6
+ "name": "ssheleg"
7
7
  },
8
- "homepage": "https://github.com/appvillis-com/agent-sync",
9
- "repository": "https://github.com/appvillis-com/agent-sync",
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.3.5"
8
- author: appvillis-com
7
+ version: "1.4.0"
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
- on any of: a register file that does not exist or whose allocation pattern matches
65
- nothing; a guard glob that matches no file (a rule that protects nothing); a claim-tag
66
- pattern with nothing to look for; a gate command whose script is missing; a mirror source
67
- that is not there; missing or empty credentials; a `.gitignore` that does not cover the
68
- env file — or the env file being **tracked by git**, which is the one unrecoverable
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 before writing them. A
92
- register pointed at the wrong file makes every later check confidently wrong, and a
93
- guarded list that misses a shared file leaves the one place collisions actually happen
94
- unprotected. In a submodule it declares no registers at all: decisions belong to the
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 for you:
161
-
162
- - No credentials in the environment degraded mode, reported, and it continues.
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
- | `scaffold` | Create the missing documentation architecture. Never overwrites |
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 is the one that matters, because a plain shell command has no session id and a hook does.
213
- So `SessionStart` stamps `.agent-sync/sessions/<the CLI's pid>` with the session it knows, and a
214
- later command finds itself by walking its own process ancestry to a pid that appears there. It is
215
- exact, and it deliberately does **not** parse command lines: the throwaway shell every tool call
216
- runs in carries claude paths in its own argv, so every heuristic aimed at the CLI binary matched it
217
- instead. Stamps are removed when their process is gone.
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
- area you are about to touch. Resolve each divergence: the git doc is stale, or the
283
- as-built record is wrong, or they genuinely disagree and that is a decision to make.
284
- Building on an unresolved divergence means writing code against a system that does
285
- not exist.
286
- - **After finishing** (docs stage) — `record` what you actually built, update the git
287
- documents that state intent, then `reconcile` again. A task that updated one side
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
- | Stage | What to do here |
306
- |---|---|
307
- | 0 Intake grill | Add the cloud KB and the board to the harvest's source ledger; `acquire` **before** the brief is committed |
308
- | 1 Docs study | `reconcile` — study git docs **and** the as-built record, resolve every divergence before writing code |
309
- | 2 Brainstorm | `journal`; warn if a live run holds an overlapping key |
310
- | 3 Spec | `reserve` every id before writing it to git |
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/appvillis-com/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
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, write the claim tag through | A run that ends without releasing looks alive until its TTL expires |
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
 
@@ -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.3.5"
35
+ VERSION = "1.4.0"
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",