@ssheleg/agent-sync 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,142 @@
1
+ ## v1.8.0
2
+
3
+ **The board, cleared.** Nine rows opened by the two audits of 2026-08-10, closed with a check, a
4
+ measurement or a written decision — because a backlog nobody empties is a list of things everyone
5
+ has agreed to stop seeing.
6
+
7
+ ### The two scenarios that were only ever driven by hand are now gated
8
+
9
+ Two agents contending for one task, and the guard across every shape a write arrives in, were
10
+ verified by executing them and had no check that fails on its own — the exact state in which the
11
+ first audit found six shipped defects. `check_two_agents_cannot_share_one_task` drives the full
12
+ sequence from two identities: the second run loses, is told who holds it, sees the holding in
13
+ `status`, is denied the guarded registry, cannot release a lease it does not hold, and the holder
14
+ can. `check_guard_covers_every_write_shape` drives nine payloads through the real hook — `Edit`,
15
+ `Write`, `NotebookEdit`, an unguarded file, `git commit`, `git -C <dir> commit`,
16
+ `cd <dir> && git commit`, `git log --grep=commit`, and malformed input — with and without the lease.
17
+
18
+ Planting the first defect took two mutations, not one, and that is worth recording: breaking only
19
+ `acquire`'s expiry check still refuses the steal, because `_steal_expired` re-reads the expiry
20
+ **inside** the critical section. The exclusion has two independent layers. The single-point fixture
21
+ was MISSED, which is how that was discovered.
22
+
23
+ ### `check_merge_releases_only_its_key`
24
+
25
+ `merge --key` must release that lease and leave the others held. It was verified by reading the
26
+ code, and every defect the first audit found was in something verified by reading.
27
+
28
+ ### `status` on a large repository: 3.2 s → 0.5 s
29
+
30
+ `check` resolved guarded and claim-tag patterns by walking the whole tree **once per pattern** —
31
+ five patterns over a 20 000-file repository is five full walks, measured at 3.2 s for a single
32
+ `status`, which is a `SessionStart` hook. It now walks once, and uses git's own index where there is
33
+ one, so `.gitignore` is honoured for free. Same output, six times faster.
34
+
35
+ ### `check_commands_work_without_the_family_installed`
36
+
37
+ Runs the commands with `HOME` redirected at an empty directory — the state of every CI runner, and
38
+ where the task-pipeline ordering defect was obvious while this development machine could not see it.
39
+ The check proves its own isolation on a healthy project before asserting on a broken one, because a
40
+ probe that cannot fail is not a probe.
41
+
42
+ ### The self-test: 6 min → 2.8 min
43
+
44
+ Each fixture now runs as its own process, eight at a time. It was a loop that reassigned module
45
+ globals and called `main()` in-band, once per fixture; at 32 fixtures it had already blown a
46
+ ten-minute command budget, and a suite people stop running is a suite that does not exist. The
47
+ subprocess also removes the global-state reset that made parallelism impossible.
48
+
49
+ ### Decisions recorded rather than deferred again
50
+
51
+ **`settleSeconds` stays.** It is the adapter contract's extension point for a store whose writes are
52
+ not immediately readable; removing it would fail every config that carries one to buy nothing.
53
+ Written into `references/adapter-contract.md`, where the next reader will look.
54
+
55
+ **Blockers stay gone.** The concept needs a writer, a reader and a place on the board before the
56
+ word earns a document.
57
+
58
+ **Guard latency is fine:** 130–220 ms per guarded edit with no run id in the environment, against a
59
+ `PreToolUse` budget of 20 s. Measured, recorded, no change.
60
+
61
+ ### Also
62
+
63
+ `actions/checkout@v5`, `actions/setup-node@v5`, `actions/setup-python@v6` — the v4 pins were being
64
+ forced onto Node 24 by the runner and annotated every release.
65
+
66
+ `merge` also gained a preflight it was missing. A merge commit is authorship, so it needs a real
67
+ git identity — unlike a lease object, which is plumbing and is written with a synthetic one on
68
+ purpose. Without one the merge started, git refused at the commit, and the abort path ran: it
69
+ recovers, and it is still not what a command whose whole doctrine is *every check before anything
70
+ is touched* promises. Found by CI, because a runner has no global `.gitconfig` and this machine
71
+ does — the same blind spot, twice in one day.
72
+
73
+ The validator now runs 44 checks and plants and catches 37 distinct defects.
74
+
75
+ ## v1.7.1
76
+
77
+ **A second audit, along a different axis: not whether the tool keeps its promises, but whether an
78
+ agent reading this skill can follow it and get a correct result.** The scenarios were executed, not
79
+ imagined — cold start, the whole adoption chain, the per-task cycle, two agents contending for one
80
+ task, every hook with a realistic payload, and the edges (no git repository, uninitialised project,
81
+ absolute paths, `git -C <dir> commit`). The coordination core came through clean. What did not was
82
+ everything the agent *reads*.
83
+
84
+ ### The documents the tool generates were two versions behind the doctrine it teaches
85
+
86
+ `setup` writes the snapshot every agent is told to read first — *"it states how documentation and
87
+ coordination work here"* — and `scaffold` seeds `AGENTS.md`. Neither mentioned a branch or `merge`
88
+ even once. Both prescribed a cycle ending in `release`, and the snapshot stated *"the claim tag in
89
+ git is written through"* as an unconditional fact, which has been false on any branch since 1.4.0 —
90
+ the branch being where the doctrine says the work belongs.
91
+
92
+ So an agent doing exactly what the skill instructs — trust the generated snapshot — got the
93
+ workflow from two releases ago. Regenerating did not help: the generator was what was stale. Both
94
+ now carry the branch rule, `merge --key`, and this project's integration branch by name.
95
+
96
+ `AGENTS.md` stopped restating the cycle altogether. It is seeded once and **never overwritten**, so
97
+ a copy of the protocol there is frozen on the day the project was created while the tool moves on —
98
+ and every project scaffolded before today would have kept the old one forever. It now points at the
99
+ snapshot, which is regenerable and which `check` fails on when it goes stale. One fact, one home.
100
+
101
+ ### `check` blessed a project an agent cannot work in
102
+
103
+ A configuration declaring `idRegisters` on a backend whose `reserve` always raises passed as
104
+ `setup healthy`. `check`'s own promise is that it refuses a rule pointing at what is not there, and
105
+ a register nobody can allocate from is exactly that — while the snapshot it generates instructs
106
+ every agent to run `agent_sync.py reserve DEC`, which cannot succeed there. It is now a problem,
107
+ named with both ways out.
108
+
109
+ ### The slash command offered a verb the CLI does not have
110
+
111
+ `argument-hint` advertised `claim <KEY>` and the README showed `/agent-sync claim ASC-072`. The
112
+ command is `acquire`; `claim` is an `invalid choice`. First thing an agent reads, first thing it
113
+ types.
114
+
115
+ ### `$SKILL_DIR` was used in every example and defined nowhere
116
+
117
+ Six invocations tell the agent to run `python3 "$SKILL_DIR/scripts/agent_sync.py"`, and the only
118
+ explanation was the prose *"this skill's own directory"*. Nothing gave a value. The Cursor rule
119
+ names a concrete path; the skill body now names both — `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
120
+ and `~/.agents/skills/agent-sync`.
121
+
122
+ ### What the audit found working
123
+
124
+ Worth recording, because a report that only lists faults says nothing about the rest: two agents
125
+ contending for one task behave correctly end to end — the second loses, sees who holds it and in
126
+ which repository, is denied the guarded file, and cannot release a lease it does not hold. The
127
+ guard denies `Edit`, `Write`, `NotebookEdit` and absolute paths, and blocks `git commit`,
128
+ `git -C <dir> commit` and `cd <dir> && git commit` when a guarded file is staged, while letting
129
+ `git log --grep=commit` and malformed JSON through. `SessionStart` stamps the identity and prints
130
+ the awareness block; `SessionEnd` releases. The adoption chain works with and without a pre-existing
131
+ `docs/`. An uninitialised project and a non-git directory both answer with the next action and a
132
+ non-zero exit.
133
+
134
+ ### New checks
135
+
136
+ `check_every_advertised_verb_exists`, `check_generated_docs_carry_current_doctrine`,
137
+ `check_registers_need_a_backend_that_can_reserve`, `check_skill_gives_a_resolvable_script_path` —
138
+ with four more self-test fixtures. The validator now plants and catches 32 distinct defects.
139
+
1
140
  ## v1.7.0
2
141
 
3
142
  **Observability, honest degradation, and the removal of things that were never load-bearing.**
package/README.md CHANGED
@@ -225,8 +225,9 @@ action:
225
225
 
226
226
  ## Everyday use
227
227
 
228
- In an agent session you use the slash command (`/agent-sync claim ASC-072`); the same
229
- commands run directly against the coordinator script, which is what the hooks and CI do:
228
+ In an agent session you use the slash command (`/agent-sync acquire ASC-072` the verb is
229
+ `acquire`, the same one the CLI takes); the same commands run directly against the
230
+ coordinator script, which is what the hooks and CI do:
230
231
 
231
232
  ```bash
232
233
  python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding \u2014 leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "description": "Coordination layer for multi-agent repositories \u2014 leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -1,6 +1,6 @@
1
1
  ---
2
- description: Coordinate concurrent agents — initialise the shared knowledge store, check status, claim a task, reserve an id, or regenerate the board.
3
- argument-hint: "[init|status|claim <KEY>|release <KEY>|reserve <REG>|board|finish]"
2
+ description: Coordinate concurrent agents — initialise the shared knowledge store, check status, take a task, reserve an id, or regenerate the board.
3
+ argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|board|merge|finish]"
4
4
  ---
5
5
 
6
6
  Invoke the `agent-sync` skill.
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.7.0"
7
+ version: "1.8.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -113,10 +113,9 @@ storage question gets asked and answered, once, and written down.
113
113
  - or local files (`fs`) — no credentials, and no visibility to an agent on another
114
114
  machine: no shared awareness, no cross-repo signals, no shared board.
115
115
 
116
- The lease is decided separately by `leaseBackend` `git` for cross-machine exclusion,
117
- `local` otherwise — and **`gated` follows that choice, never the record plane**. `fs`
118
- with a local lock is still real exclusion between the agents on this machine; `outline`
119
- with a local lock is *not* exclusion across them. Report the one you actually have.
116
+ The lease is decided separately by `leaseBackend` (trap 2), and **`gated` follows that
117
+ choice, never the record plane**. Report the guarantee you actually have, not the
118
+ stronger one the record plane suggests.
120
119
  2. **If cloud: the instance URL.** The URL is configuration, not a secret, so you
121
120
  may write it. The **token is not** — you never ask for it in chat, never read it
122
121
  back, and never place it yourself.
@@ -144,9 +143,9 @@ the file is their step, and the design depends on it staying theirs.
144
143
  python3 "$SKILL_DIR/scripts/agent_sync.py" status
145
144
  ```
146
145
 
147
- Idempotent. Inspects, repairs what is missing, prints a status block, names exactly
148
- ONE next action — and carries `check`'s verdict, so the command every session runs and
149
- the command that validates the setup cannot give two answers about one project.
146
+ Idempotent. Inspects, repairs, reports, names exactly ONE next action and carries
147
+ `check`'s verdict, so the command every session runs and the one that validates the
148
+ setup cannot give two answers about one project.
150
149
 
151
150
  **Read the two awareness sections it prints — they are the point, not decoration.**
152
151
 
@@ -196,7 +195,9 @@ npx sshlg-skills install
196
195
  | `scaffold [--full]` | Create only what is missing, never a line over anything that exists. `--full` also seeds the question register, the index, the dependency board, the data model with its entity register, and the docs gate |
197
196
  | `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, no lease left held. `check` answers whether the project is wired correctly; this answers whether you are done |
198
197
 
199
- `$SKILL_DIR` is this skill's own directory. Every command reads
198
+ `$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
199
+ under the Claude Code plugin, `~/.agents/skills/agent-sync` elsewhere. Resolve it once
200
+ per session and reuse it — do not guess a path. Every command reads
200
201
  `.claude/agent-sync.json` from the project root and needs no arguments beyond those
201
202
  listed.
202
203
 
@@ -321,16 +322,10 @@ Two files, and the split between them is the whole security model.
321
322
  **`.claude/agent-sync.json`** — *shape*, committed: which backend, TTLs, which files
322
323
  are guarded, which registers exist, which gates to run.
323
324
 
324
- **`.env.agent-sync`** — *identity*, created by `init`, mode 600, gitignored:
325
-
326
- ```
327
- AGENT_SYNC_BACKEND=outline
328
- AGENT_SYNC_OUTLINE_URL=https://<instance>
329
- AGENT_SYNC_OUTLINE_TOKEN= # the operator fills this line, nobody else
330
- AGENT_SYNC_OUTLINE_COLLECTION= # printed by `bootstrap`
331
- ```
332
-
333
- Load it before running agents:
325
+ **`.env.agent-sync`** — *identity*, written by `init` with the keys already in it, mode
326
+ 600, gitignored. The operator fills the token line and nobody else; `bootstrap` prints
327
+ the collection id to paste beside it. Found here, or in a superproject, or wherever
328
+ `AGENT_SYNC_ENV` points — `check` prints which. Load it before running agents:
334
329
 
335
330
  ```bash
336
331
  set -a && . ./.env.agent-sync && set +a
@@ -1,3 +1,11 @@
1
+ > **`settleSeconds` is this contract's extension point, and nothing shipped reads it.**
2
+ > It exists for an adapter whose writes are not immediately visible to a subsequent read —
3
+ > a store with an asynchronous index, where a reader must wait before replaying. Neither
4
+ > `outline` nor `fs` needs it, so `check` says plainly that setting it does nothing here.
5
+ > It is kept rather than removed because deleting it would make every config carrying one
6
+ > fail validation to buy nothing, and because an adapter that needs it has nowhere else to
7
+ > ask. If you add a backend that waits, this is the knob; if you do not, ignore it.
8
+
1
9
  # Adapter contract
2
10
 
3
11
  **Read this when** adding a knowledge backend, auditing one, or deciding whether a
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.7.0"
36
+ VERSION = "1.8.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -231,18 +231,41 @@ def matches_glob(rel: str, pattern: str) -> bool:
231
231
  return bool(rx.match(rel.replace(os.sep, "/")))
232
232
 
233
233
 
234
- def glob_files(root: Path, pattern: str) -> list[Path]:
235
- """Every existing file the pattern covers, by the same rule the guard applies."""
234
+ def _tracked_files(root: Path) -> list[str]:
235
+ """Every candidate path in the repository, walked ONCE.
236
+
237
+ `glob_files` used to walk the whole tree per pattern, and `check` calls it for every
238
+ guarded pattern and every claim-tag pattern — five patterns over a 20 000-file
239
+ repository is five full walks. Measured at ~3 s for a single `status`, which since
240
+ 1.7.0 runs `check`, and `status` is a `SessionStart` hook. Git's own index is used
241
+ where it exists, because it already excludes everything `.gitignore` does; the walk
242
+ is the fallback for files not yet tracked.
243
+ """
244
+ listed = git("ls-files", "--cached", "--others", "--exclude-standard", cwd=root)
245
+ if listed:
246
+ return listed.split("\n")
236
247
  skip = {".git", "node_modules", STATE_DIR.name}
237
- out: list[Path] = []
248
+ out: list[str] = []
238
249
  for path in root.rglob("*"):
239
- if skip & set(path.relative_to(root).parts):
250
+ rel = path.relative_to(root)
251
+ if skip & set(rel.parts):
240
252
  continue
241
- if path.is_file() and matches_glob(str(path.relative_to(root)), pattern):
242
- out.append(path)
253
+ if path.is_file():
254
+ out.append(str(rel))
243
255
  return out
244
256
 
245
257
 
258
+ def glob_files(root: Path, pattern: str) -> list[Path]:
259
+ """Every existing file the pattern covers, by the same rule the guard applies."""
260
+ return [root / rel for rel in _tracked_files(root) if matches_glob(rel, pattern)]
261
+
262
+
263
+ def glob_files_many(root: Path, patterns: list[str]) -> dict[str, list[Path]]:
264
+ """The same answer for several patterns, from one walk."""
265
+ files = _tracked_files(root)
266
+ return {p: [root / rel for rel in files if matches_glob(rel, p)] for p in patterns}
267
+
268
+
246
269
  # --------------------------------------------------------------------------- config
247
270
 
248
271
  def find_env_file(root: Path) -> Path | None:
@@ -2094,19 +2117,29 @@ class Sync:
2094
2117
  "## The cycle, per task",
2095
2118
  "",
2096
2119
  "```",
2097
- "statuswho else is working, and what changed while you were away",
2120
+ "merges → what landed while you were away",
2121
+ "status → who else is working, and what changed since you last looked",
2098
2122
  "reconcile → resolve every divergence BEFORE writing code",
2099
- "acquire ID take the lease; the claim tag in git is written through",
2123
+ "branch work happens on one; the integration branch is somebody",
2124
+ " else's stable base",
2125
+ "acquire ID → take the lease. On the integration branch the claim tag is",
2126
+ " written through to git; on any other branch the holder stays",
2127
+ " in the coordination plane, where `status` shows it to everyone",
2100
2128
  " … work …",
2101
2129
  "record → what you ACTUALLY built, with the decision id and files",
2102
2130
  " … update the git documents in the same change …",
2103
2131
  "reconcile → check both sides again",
2104
2132
  "board → regenerate the shared view",
2105
- "release ID on every path, including failure",
2133
+ "merge --key land the branch: conflicts checked first, the merge recorded,",
2134
+ " that lease released. Without a branch, `release ID` by hand",
2135
+ " — on every path, including failure",
2106
2136
  "```",
2107
2137
  "",
2138
+ f"This project's integration branch is `{self.integration_branch}`.",
2139
+ "",
2108
2140
  "Full doctrine ships with the skill: `references/two-sources.md`,",
2109
- "`references/lease-protocol.md`, `references/pipeline-binding.md`.",
2141
+ "`references/lease-protocol.md`, `references/branching.md`,",
2142
+ "`references/roadmap.md`, `references/pipeline-binding.md`.",
2110
2143
  ]
2111
2144
  return "\n".join(L) + "\n"
2112
2145
 
@@ -2889,31 +2922,24 @@ annotate the old entry's status line. The body of the old entry stays as history
2889
2922
 
2890
2923
  AGENTS_SEED = """# AGENTS.md — working protocol
2891
2924
 
2892
- **Read [`{snapshot}`]({snapshot}) first.** It is generated from the live configuration and
2893
- states how documentation and coordination work here: which registers exist, which files
2894
- need a lease, which gates run, what is written where, and what is never deleted.
2895
-
2896
- ## Before you write anything
2897
-
2898
- Several agents may work this repository at once.
2925
+ **Read [`{snapshot}`]({snapshot}) first, and follow the cycle it states.** That file is
2926
+ generated from the live configuration: which registers exist, which files need a lease,
2927
+ which gates run, what is written where, what is never deleted, and the order to do it in.
2899
2928
 
2900
- 1. `agent-sync status` who else is working, and what changed while you were away.
2901
- 2. `agent-sync reconcile` the git documents say how it *should* be, the as-built record
2902
- says how it *is*. Resolve every divergence **before** writing code.
2903
- 3. `agent-sync acquire <TASK-ID>` the guarded registers refuse an unleased write.
2904
- 4. Reserve any new id with `agent-sync reserve <REGISTER>` before writing it.
2929
+ This file deliberately does **not** restate that cycle. It is seeded once and never
2930
+ overwritten, so a copy of the protocol here would be frozen on the day the project was
2931
+ created while the tool moved on and the two would disagree in front of an agent with no
2932
+ way to tell which is current. One fact, one home; the home is the generated snapshot,
2933
+ because it can be regenerated and `agent-sync check` fails when it goes stale.
2905
2934
 
2906
- ## When you finish
2935
+ ## The three that are true in every project
2907
2936
 
2908
- 1. `agent-sync record` what you actually built, with the decision id and the files.
2909
- 2. Update the git documents in the **same** change.
2910
- 3. `agent-sync reconcile` again, then `agent-sync board`.
2911
- 4. `agent-sync release <TASK-ID>`on every path, including failure.
2912
-
2913
- ## The one rule that matters most
2914
-
2915
- **No decision lives only in chat.** Record it in the decision register, propagate it to
2916
- every document it affects, and commit referencing the id.
2937
+ 1. **Several agents may work this repository at once.** `agent-sync status` before you
2938
+ start: who holds what, and what changed since you last looked.
2939
+ 2. **Work on a branch, and land it with `agent-sync merge`.** The integration branch is
2940
+ somebody else's stable basenothing about work in flight is committed there.
2941
+ 3. **No decision lives only in chat.** Record it in the decision register, propagate it to
2942
+ every document it affects, and commit referencing the id.
2917
2943
  """
2918
2944
 
2919
2945
 
@@ -3119,8 +3145,21 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3119
3145
  for k in sorted(set(cfg) - CONFIG_KEYS):
3120
3146
  problems.append(f"config key '{k}' is not in the schema — it will be ignored")
3121
3147
 
3122
- # Registers must exist AND their allocation pattern must actually match.
3148
+ # Registers must exist, their allocation pattern must match — and the backend must be
3149
+ # able to hand an id out at all. A register declared on a plane whose `reserve` always
3150
+ # raises is a rule that protects nothing, which is the exact thing `check` promises to
3151
+ # refuse: the generated snapshot then instructs every agent to run `reserve <REG>`, and
3152
+ # the command cannot succeed in that project.
3123
3153
  regs = cfg.get("idRegisters") or {}
3154
+ if regs:
3155
+ backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
3156
+ probe = make_adapter(cfg, root)
3157
+ if not probe.is_lease_authority:
3158
+ problems.append(
3159
+ f"{len(regs)} id register(s) declared, but backend '{probe.name}' cannot "
3160
+ f"reserve ids (atomicAppend is false){' — the configured backend is ' + backend + ' and it is not reachable, so runs degrade to fs' if backend != probe.name else ''}. "
3161
+ "`reserve` fails every time here; either configure a backend that can "
3162
+ "allocate, or remove the registers and allocate them in the parent repository")
3124
3163
  for reg, spec in sorted(regs.items()):
3125
3164
  f = root / spec.get("file", "")
3126
3165
  if not f.exists():
@@ -3139,9 +3178,11 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3139
3178
  ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
3140
3179
 
3141
3180
  # A guard glob that matches nothing protects nothing. Resolved by the same function
3142
- # the guard itself applies, so the two commands cannot disagree about one pattern.
3181
+ # the guard itself applies, so the two commands cannot disagree about one pattern
3182
+ # and from one walk of the repository, not one per pattern.
3183
+ guard_hits = glob_files_many(root, list(cfg.get("guardedFiles") or []))
3143
3184
  for pattern in (cfg.get("guardedFiles") or []):
3144
- hits = glob_files(root, pattern)
3185
+ hits = guard_hits.get(pattern) or []
3145
3186
  if not hits:
3146
3187
  problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
3147
3188
  if cfg.get("guardedFiles"):
@@ -3149,8 +3190,9 @@ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
3149
3190
  else:
3150
3191
  warn.append("no guarded files — nothing requires a lease in this repository")
3151
3192
 
3193
+ claim_hits = glob_files_many(root, list(cfg.get("claimTags") or {}))
3152
3194
  for pattern, spec in (cfg.get("claimTags") or {}).items():
3153
- files = glob_files(root, pattern)
3195
+ files = claim_hits.get(pattern) or []
3154
3196
  if not files:
3155
3197
  problems.append(f"claimTags pattern '{pattern}' matches no file")
3156
3198
  continue
@@ -3381,6 +3423,21 @@ def cmd_merge(args: argparse.Namespace) -> int:
3381
3423
  raise Fail(f"working tree is not clean ({len(dirty)} path(s)) — commit or stash first; "
3382
3424
  "a merge cannot tell your uncommitted work from the branch's")
3383
3425
 
3426
+ # A merge commit is authorship, so it needs a real identity — unlike a lease object,
3427
+ # which is plumbing and is written with a synthetic one on purpose. Checked HERE
3428
+ # because everything in this command is checked before anything is touched: without
3429
+ # it the merge starts, git refuses at the commit, and the abort path runs. That is
3430
+ # recoverable and it is still not what this command promises. It is also the ordinary
3431
+ # state of a CI runner and a fresh container, which is where it was found.
3432
+ ident = subprocess.run(["git", "var", "GIT_COMMITTER_IDENT"],
3433
+ capture_output=True, text=True)
3434
+ if ident.returncode != 0:
3435
+ why = (ident.stderr or "").strip().splitlines()
3436
+ raise Fail("git has no committer identity here, so the merge commit cannot be "
3437
+ "written. Set one and run this again — nothing was touched:\n"
3438
+ " git config user.name '<you>' && git config user.email '<you@example>'"
3439
+ + (f"\n ({why[-1]})" if why else ""))
3440
+
3384
3441
  git("fetch", "--quiet", "origin", target)
3385
3442
  upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
3386
3443