@kaisers-io/refs 0.10.0 → 0.12.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +398 -1
  2. package/dist/refs.mjs +46 -40
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,401 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-09-08
11
+
12
+ ### Upgrading
13
+
14
+ Run `refs sync` after updating. Two things are worth expecting on that first run.
15
+
16
+ **Packages may be reported as `missing` that are still on disk.** Negated workspace patterns are
17
+ applied now, so a package a repository excludes (`!examples/vue/2*`) is no longer a workspace
18
+ member — and an entry registered for one before this release no longer verifies. Nothing is removed
19
+ automatically; the finding names the entry and leaves the decision alone.
20
+
21
+ **Packages that arrived upstream since the last sync are reported.** Only those: a package the
22
+ configuration never had and that did not arrive in the fetched range stays unmentioned, however
23
+ long it has been there.
24
+
25
+ ### Added
26
+
27
+ - **`refs sync` now reports a package that arrived upstream, and `refs edit --create` registers
28
+ it.** The drift probe checked the packages the configuration already had, so a package added
29
+ upstream after `refs add` stayed invisible — and there was no command to register one either:
30
+ `refs add` refuses an already-tracked ref, and every `refs edit` mode needs an entry to edit. The
31
+ only instruction anyone could give was "hand-edit `config.toml`".
32
+
33
+ `refs sync` answers "did upstream gain a package?" from the range it just fetched, not by
34
+ comparing a scan against the configuration. That distinction is the whole design: a scan cannot
35
+ tell a package that just arrived from one the ref's owner deliberately never tracked, because
36
+ there is no inventory of what was there before — the fetch range is that inventory. A ref whose
37
+ owner tracks 3 packages out of 140 hears about the other 137 exactly never. `refs doctor` lists
38
+ every unregistered member instead, because it was asked to.
39
+
40
+ The question it asks of that range is about package NAMES, not manifest paths. A package
41
+ renamed in place modifies its manifest rather than adding one, and a package merely moved to
42
+ another directory adds one without being new — so a path-based reading is wrong in both
43
+ directions. Only the manifests the range actually changed have to be read out of history: an
44
+ untouched manifest is byte-identical at both ends, so the name it carries now is the name it
45
+ carried before.
46
+
47
+ The repair is a command now rather than a config fragment, with the ref key filled in so it
48
+ runs as printed:
49
+
50
+ ```
51
+ refs edit 'github.com/acme/alpha' --package '@acme/new' --create --path 'packages/new' \
52
+ --description "<what it is>"
53
+ ```
54
+
55
+ It is a distinct mode, not an upsert — an ordinary field edit naming an unregistered package
56
+ still fails with `not_found`, so a typo in `--package` can never become a new entry. The
57
+ finding carries `name` and `path`, both verified against the checkout and both shell-quoted
58
+ (being verified makes a value true, not shell-safe: `zPackagePath` permits `$()` and a manifest
59
+ `name` is checked only for being non-empty), and deliberately no description: a manifest description is untrusted third-party content, and copying it moves it
60
+ into a file refs later reads as its own configuration. The skill instructs agents to propose the
61
+ registration and wait for the user to agree, rather than run it on their own initiative.
62
+
63
+ ### Fixed
64
+
65
+ - **Workspace patterns are matched by `minimatch`, the matcher npm itself uses.** Hand-written
66
+ matching disagreed with the real resolvers in five distinct ways, each found only after the last
67
+ was fixed: extglob (`@(a|b)`) read as a literal directory name, trailing slashes treated
68
+ symmetrically where `minimatch` is asymmetric, repeated separators silently matching nothing, and
69
+ two more. Delegating removes that class of defect rather than the current instance of it.
70
+
71
+ Measured before choosing: `picomatch` disagrees with `minimatch` on ten of 154 comparisons over
72
+ the shapes this scanner supports, exactly on trailing slashes and repeated separators — so it is
73
+ not a drop-in. pnpm matches through `picomatch` but normalizes first, and was measured to agree
74
+ with `minimatch` on every one of those shapes, so one matcher covers both ecosystems.
75
+
76
+ Walking stays here: containment guards, the diagnostics that say why a scan came up short, and
77
+ the deliberate one-level depth policy are unchanged. `minimatch` answers only whether a path
78
+ matches a pattern. Nothing new is installed by `refs` users — the CLI publishes a bundle with no
79
+ dependencies — and that bundle grows by 24 KB.
80
+
81
+ One behaviour improves as a consequence: a negation in a shape this scanner cannot WALK
82
+ (`!packages/{a,b}`) is now applied, because applying an exclusion needs matching and never
83
+ walking. It used to be reported as unsupported and silently ignored.
84
+
85
+ - **Negated workspace patterns are applied instead of ignored.** `!packages/fixtures` was dropped
86
+ as an unsupported shape (a v1 simplification), so `refs add` registered packages the repository
87
+ had explicitly excluded, and every finding about a repository declaring one was silenced —
88
+ TanStack Query declares two, and all hundred of its packages came back `unverifiable`. Negations
89
+ are now expanded exactly like inclusive patterns and subtracted from the result, which makes the
90
+ scan an accurate statement of membership rather than an approximation of one.
91
+
92
+ A wildcard inside the last segment (`examples/vue/2*`) is supported too, since that is the shape
93
+ real repositories exclude by, and a negation nobody can expand costs every finding about the
94
+ repository rather than just the paths it names. Glob syntax that is still unimplemented —
95
+ `{a,b}`, `?`, `[…]` — now reports `unsupported_pattern` rather than reading as a literal
96
+ directory name and silently matching nothing.
97
+
98
+ - **A monorepo can now be resolved by the name in its own root manifest.** Workspace detection
99
+ expands the globs a repository declares, and a workspace root is not one of its own targets — so a
100
+ root that names itself was registered nowhere, and `refs resolve @acme/toolkit` came back empty
101
+ for a repository that was tracked all along. `refs add` now registers a named root at `path: "."`
102
+ alongside the workspace members.
103
+
104
+ Both pnpm and Yarn address a workspace root by that name (`pnpm --filter <root-name>`,
105
+ `yarn workspace <root-name>`); npm and Turborepo use a positional handle instead. Of eighteen
106
+ well-known monorepos surveyed, eight carry a name someone would plausibly use for the repository
107
+ and ten carry a throwaway like `root` or `monorepo-root` — which is what settles it: registering
108
+ the name costs nothing where it is a throwaway, since nobody resolves `"root"`, and answers the
109
+ question where it is not.
110
+
111
+ Two things this deliberately does not do. A repository that declares no workspaces is untouched:
112
+ `refs add`'s npm fallback owns that shape, and probing the root there would displace a locator it
113
+ did not choose — as it also would where a workspace declaration selects nothing, so the package
114
+ named in an `npm:<pkg>` source survives there too. And where a workspace member already claims the
115
+ root's name — `@remix-run/react-router` is a real example, in a repository that also publishes
116
+ `react-router` — the member wins and the root is simply not registered, which costs that
117
+ repository nothing it had before. That rule lives in detection itself rather than in `refs add`,
118
+ so relocation agrees with registration: a member that moves is still found uniquely, instead of
119
+ becoming ambiguous against a same-named root and leaving `resolve` with no path for a package
120
+ that is plainly there. And a root is never reported as a package's new location: its name is an
121
+ alias for the repository, so a member that upstream deletes is reported as gone rather than as
122
+ having moved to the repository root — which would have sent a caller to the wrong directory and
123
+ described a move that never happened.
124
+
125
+ Refs tracked before this change keep the package map they were given, and no command adds one
126
+ entry to an existing ref — `refs add` refuses a tracked ref, `refs edit --package` needs an entry
127
+ to edit. So `refs sync` and `refs doctor`'s `config-drift` check now report a root the
128
+ configuration does not register, with the entry to add — including the path registration would
129
+ actually use, which is the member's rather than the root's where a workspace member declares the
130
+ same name. That costs one manifest read per ref, and a workspace scan only where there is
131
+ something to report; it is asked only of refs that already register packages, since a plain
132
+ reference repository registers none on purpose and is left alone.
133
+
134
+ The root package takes the ref's own description when its manifest carries none, which is the
135
+ ordinary case for a private workspace root. That is not the per-package fallback `refs add`
136
+ otherwise refuses: the root is not a package beside the repository, it is that repository.
137
+
138
+ - **A failed lookup no longer reads as an absent repository.** `refs resolve` exits `4` when a query
139
+ matches nothing, and the message ended "run refs list, or add it: `refs add <url>`". That second
140
+ half is a guess: a query can miss every route while the repository is tracked perfectly well under
141
+ another identifier — a monorepo root whose own package name was never registered, for instance.
142
+ An agent read the suggestion as confirmation and told someone a repository they had tracked was
143
+ not tracked, then stopped.
144
+
145
+ The message now states what was searched and points at evidence rather than prescribing a fix, and
146
+ `--json` carries a `reason` on `resolve`'s routing misses: `unmatched_query` (nothing matched, by
147
+ any route), `package_not_registered` (the ref is tracked and registers no such package), or
148
+ `ref_not_registered` (a canonical git url named an absent ref — the one case where adding it is
149
+ the right answer, since only a canonical url establishes which ref was meant). There is deliberately no reason meaning "this repository does not exist", because nothing
150
+ refs can observe establishes that; and `reason` is absent on every other `not_found`, where its
151
+ absence means no narrowing is available rather than being a fourth value.
152
+
153
+ The skill's instruction changed with it. It used to say exit `4` means the ref is not tracked; it
154
+ now says exit `4` means the query matched no route, and requires a second lookup before any
155
+ conclusion. `refs resolve --ref <ref>`'s own miss also stopped suggesting a bare `refs show`,
156
+ which reports a package count and no names — it now suggests `--packages`, which actually shows
157
+ the map the reader was sent to inspect.
158
+
159
+ - **A stale-lock reclaim could delete a lock another process was using.** refs reclaims a lock left
160
+ behind by a crashed process. The check that decided a lock was abandoned and the removal that
161
+ acted on it were two separate steps, and in the gap between them the lock could legitimately
162
+ become somebody else's: the original holder releases, a waiting process takes the same path, and
163
+ the reclaim then deletes a lock that is actively in use. Both processes go on to `reset --hard`
164
+ the same checkout.
165
+
166
+ Three changes close it for processes running this version:
167
+
168
+ - **Only a process the operating system reports as gone is reclaimed from automatically.** A lock
169
+ whose lease has run out but whose process still answers is now reported rather than taken — a
170
+ live process can release at any instant, and that release is what opened the gap. Same for a
171
+ lock whose metadata never finished being written, or carries no usable identity.
172
+ - **The acquisition is re-identified after the death check.** Proving the recorded process gone is
173
+ not enough on its own: the metadata is read first and the process probed after, so the path can
174
+ change hands in between and the probe then answers about the departed owner. Re-reading the
175
+ identity immediately before the removal is what ties the two together.
176
+ - **The marker that stops two reclaims colliding no longer expires.** It used to be taken over
177
+ after two seconds, so it only excluded a reclaim fast enough to finish inside that window; a
178
+ suspended one lost its marker mid-work and a second reclaim started on the same lock. Age is
179
+ not evidence of abandonment.
180
+
181
+ The protocol's own markers also moved into `locks/.claims/` and `locks/.tombstones/`, which no
182
+ lock name can reach — lock names must start with a letter or digit. That removes a collision
183
+ where a repository literally named `foo.steal-claim` produced the marker path of the lock for
184
+ `foo`, and with it the name-shape guessing `refs doctor` needed to tell the two apart.
185
+
186
+ **What this costs.** Three situations no longer recover on their own and need one explicit
187
+ command, which `refs doctor` prints along with the condition for running it safely — stop every
188
+ refs process on that home first, suspended ones included: a crash after the operating system has
189
+ reused the process id, a crash before the lock finished writing its metadata, and a crash while a
190
+ reclaim was starting.
191
+
192
+ The messages changed to match. `refs doctor`'s `locks` check separates a lock refs will reclaim
193
+ by itself from one it will not, and the failure message when a lock cannot be acquired no longer
194
+ says "already reclaimable — retry" for a lock nothing will ever reclaim. It also stopped
195
+ describing the window as the thing that frees a lock: waiting does not, and only a recorded
196
+ process the operating system reports as gone does.
197
+
198
+ **What it does not fix.** A refs process running an _older_ version follows none of this and
199
+ reclaims on its own terms. And no lock protocol can help when refs is hard-killed while its `git`
200
+ child survives: the successor's lock is honest about the lock, not about the directory.
201
+
202
+ - **Two unrelated refs could share one lock.** The per-ref lock name replaced `/` with `_`, and `_`
203
+ is legal inside a ref key — so `github.com/acme_tools/widget` and `github.com/acme/tools_widget`
204
+ both derived `ref.github.com_acme_tools_widget`. The two then serialized against each other:
205
+ `sync` fans out four refs at a time and the loser failed on a lock conflict after the timeout,
206
+ `resolve`'s verification could block on a sync of a ref it has nothing to do with, and `doctor`'s
207
+ `config-drift` check reported the wrong ref as busy.
208
+
209
+ The name is now injective. A key containing no `_` encodes exactly as before, so it keeps the
210
+ lock name refs has always written for it; a key containing one moves into an escaped form under
211
+ `ref._`, where `_` becomes `_u` and `/` becomes `_s`. The two forms cannot collide, because a ref
212
+ key always starts with `[a-z0-9]` and so a plain name never begins `ref._`.
213
+
214
+ A lock name is one directory entry, so a key long enough to overflow one now falls back to a
215
+ digest under `ref.__` rather than failing `mkdir` with `ENAMETOOLONG`. The budget reserves room
216
+ for the sibling entries the steal protocol derives from a lock name — a name that could be
217
+ created but not renamed to its tombstone would strand an abandoned lock that nothing could then
218
+ reclaim, leaving the ref blocked until someone deleted the directory by hand. Both were already
219
+ true before this change, for keys past roughly 200 characters; reaching it needs a self-hosted
220
+ url, since no forge allows a path that long.
221
+
222
+ **One caveat if you run refs concurrently across an upgrade.** The lock name changes for a ref
223
+ whose key contains `_`, and for one long enough to reach the digest form — roughly 200
224
+ characters, either way. For such a ref, a process from any earlier release (the old scheme dates
225
+ to 0.1.1) derives a different name from a new one, so for the length of that overlap the two
226
+ would not exclude each other. The window is a mid-upgrade concurrent run on the same refs home;
227
+ if that is a situation you can be in, let running operations finish before upgrading. Every other
228
+ ref keeps its name and is unaffected.
229
+
230
+ ## [0.11.0] - 2026-08-31
231
+
232
+ ### Changed
233
+
234
+ - **The skill's version-question flow moved into its own file.** Every question about a
235
+ dependency's source loads `SKILL.md` and `INVESTIGATE.md` in full, and 30 % of
236
+ `INVESTIGATE.md` was a block on resolving versions to tags and diffing between them — read
237
+ on every plain source question, used on almost none of them. It is now `VERSIONS.md`, with a
238
+ route of its own in `SKILL.md` §5 and a one-line pointer at the end of `INVESTIGATE.md`, so
239
+ a mis-route costs one extra read rather than a wrong answer.
240
+
241
+ Alongside it, a compression pass over what remains: the worker output contract was stated
242
+ twice and is now stated once, the five clickable-link rules became one normalization, and a
243
+ handful of sentences that repeated something said a few lines earlier are gone. Nothing
244
+ behavioural was removed — the capability gate, the trust boundary, the five hard rules and
245
+ the worker prompt's own safety rules are untouched, and the measured partial-clone cost model
246
+ (which git commands fetch blobs, and that `git blame` fetches one per visited revision) is
247
+ refs' own measurement rather than something the term "partial clone" implies, so it stays
248
+ verbatim.
249
+
250
+ A plain source question now loads 20 % less. A version question loads about what it did
251
+ before, plus one extra file read.
252
+
253
+ ### Added
254
+
255
+ - **`refs sync` and `refs doctor` now report configuration that has fallen behind its upstream.**
256
+ A configured package path is only a locator, and upstream can delete or move what sits at it.
257
+ Until now only `refs resolve` noticed, for the one package an agent happened to route to, and it
258
+ persisted nothing — so a package deleted upstream could sit wrong in the configuration
259
+ indefinitely while every other package in the same checkout went uninspected.
260
+
261
+ Each successful `refs sync` result now carries a nested `structure: {status, packages}`, probed
262
+ inside the lock the sync already holds, right after the checkout was updated. Nothing is stored:
263
+ the answer is reported and thrown away, so there is no drift state that can itself go stale. A
264
+ removal and a relocation are reported as different findings, because they need opposite repairs —
265
+ telling an agent to "fix the path" of a package upstream deleted sends it looking for something
266
+ that is not there. Human output gains indented lines under the affected ref and stays silent when
267
+ everything resolves; the summary counts and exit code are untouched, since a drifted ref synced
268
+ perfectly well.
269
+
270
+ Only refs that actually sync are probed, which keeps `--stale-only` a genuine no-op — and is why
271
+ `refs doctor` gains a `config-drift` check as the deliberate "check everything now" counterpart.
272
+ It takes each ref's lock with a short timeout and reports the ref as busy rather than waiting,
273
+ writes nothing, and reports `warn` rather than `fail`: the configuration has fallen behind,
274
+ nothing is broken. `refs list` deliberately stays blind — without stored state it would
275
+ turn a cheap inventory command into a locking filesystem sweep.
276
+
277
+ - **`refs resolve` answers in one call what used to take three.** The skill's investigation flow
278
+ began `resolve` → `sync` → `resolve` **again**, and the third call was not ceremony: package
279
+ verification had described the checkout as it was _before_ the sync, so reusing that answer meant
280
+ reporting a path that no longer necessarily held what it claimed. `--sync-if-stale` fetches (or
281
+ clones) only when the ref is stale or its checkout absent, and everything it reports describes the
282
+ checkout afterwards. The rule has left the skill and become code.
283
+
284
+ It refuses, rather than syncing, when the checkout is `unmanaged` or `unverifiable`. `sync`
285
+ hard-resets and cleans; running it against a directory whose identity was never established is
286
+ how a stray clone loses its history. A failing sync fails the command rather than returning a
287
+ success envelope containing a stale path.
288
+
289
+ - **`refs resolve --project <dir>` reports the version a project has installed.** The skill used to
290
+ tell the agent to read the project's lockfile by hand, and nothing in refs touched one — so the
291
+ deterministic half of every "what changed between my version and a newer one" question was done
292
+ by the least deterministic component available, against pnpm's peer-qualified keys, aliases,
293
+ overrides and three vendor-specific formats.
294
+
295
+ The answer is read from `node_modules`, walking up in Node's own lookup order, and stops at the
296
+ first installation slot that exists rather than the first readable manifest — falling through to
297
+ an ancestor would report a shadowed install Node would not have loaded. There is deliberately no
298
+ lockfile fallback: a lockfile says what _should_ be installed, `node_modules` says what _is_, and
299
+ the second is the question. `installed.status` is `found`, `not_materialized`,
300
+ `unsupported_layout` (Yarn PnP, detected but never loaded — `.pnp.cjs` is project code) or
301
+ `unverifiable`.
302
+
303
+ - **`refs resolve --ref <ref>`** scopes a query to one ref's packages. A package name registered by
304
+ several refs used to be answered with "use the full ref key" — advice the command could not
305
+ honour, because a full-key query routes by _ref_ and comes back with `package: null`. The error
306
+ now names a remedy that exists.
307
+
308
+ - `refs doctor` gained a `locks` check. A held lock used to be invisible: acquisition failed with a
309
+ message that named no owner, and `doctor` had no lock check at all — so the one command meant to
310
+ answer "is something stuck?" could not see the thing that was stuck. The check lists every entry
311
+ in the locks directory with its recorded owner, how long it has been held, and against which
312
+ window.
313
+
314
+ A held lock is **not** a warning by itself: that is what a concurrent `refs sync` looks like, so
315
+ it reports `ok` with the holder listed. `warn` is reserved for something that will not resolve on
316
+ its own — a recorded process that is gone, a lock past its window and still there, metadata that
317
+ cannot be read, or something that is not a lock at all occupying a lock name. Like every other
318
+ `doctor` warning, it does not change the exit code.
319
+
320
+ ### Changed
321
+
322
+ - **`refs resolve` establishes that the path it hands back is really this ref's checkout.** It used
323
+ to report presence from a `.git` entry alone, while `add` and `sync` both ran a stronger guard
324
+ before mutating — so the one command whose result is read as "the source is here" was the one
325
+ that did not check what was there. A manual clone at the derived path, a half-finished `remove`,
326
+ a restored backup or a symlinked second home all produced a confident answer about the wrong
327
+ repository, with no error and no warning.
328
+
329
+ Every reply now carries `checkout: {status, reason?}` — `managed`, `missing`, `unmanaged` or
330
+ `unverifiable` — read straight out of `.git/config` without spawning git, so the hot path stays
331
+ subprocess-free. The origin URL is never echoed back in `reason`; it can carry credentials.
332
+
333
+ `managed` requires the `core.hooksPath` marker to be **this home's** hooks directory, not merely
334
+ present — the comparison `add` already makes — so a manual clone that sets it for its own purposes
335
+ does not pass. A config git itself would reject (an unterminated quote, an undefined escape, a
336
+ line that is neither a section nor an assignment) is `unverifiable` rather than partially read: a
337
+ file git would not accept is not evidence of identity.
338
+
339
+ **Package verification is gated on it.** A manifest read inside an unrelated checkout can answer
340
+ `verified` for a package that has nothing to do with the query, so anything other than `managed`
341
+ or `missing` now yields `package.status: "unverifiable"` instead of a confident location.
342
+
343
+ `missing` is unchanged and still means `checkout.status === "missing"`. Callers should branch on
344
+ `checkout.status`, which answers the question `missing` was often assumed to.
345
+
346
+ - **The "lock is held" error now says who holds it and for how long.** It used to read `lock <name>
347
+ is held — another refs process is running`, which left no way to tell a running `sync` from
348
+ something that crashed 90 seconds ago. It now names the recorded pid, whether that pid is still
349
+ present, how long the lock has been held, and when it becomes reclaimable.
350
+
351
+ It deliberately says "recorded pid … is present (identity not verified)" rather than "held by pid
352
+ …": only `ESRCH` establishes that a process is gone, so a pid that answers may equally be an
353
+ unrelated process that reused the number. And it says "reclaimable", never "released
354
+ automatically" — nothing removes a lock in the background; the phrase means the next acquisition
355
+ attempt is entitled to take it.
356
+
357
+ - **An operation that ran without the lock it asked for now fails instead of reporting success.**
358
+ A lock can still be lost while its holder works — a stolen lock is detected by the next renewal,
359
+ or by release finding a foreign token. Previously the callback's result was returned as if
360
+ nothing had happened. It is now reported as a `conflict` (exit code 5), because the work ran
361
+ without the mutual exclusion it requested and its result is not trustworthy. An operation that
362
+ failed on its own keeps precedence: its own error is what the caller sees.
363
+
364
+ ### Fixed
365
+
366
+ - The `rm -rf` commands `refs doctor` and `refs add` suggest are now quoted. Ref keys derive from
367
+ user-supplied urls and permit spaces, `$()`, backticks, semicolons and quotes, and the refs home
368
+ itself routinely sits under a path containing a space — so pasting an unquoted suggestion could
369
+ delete several wrong paths and leave the intended one, or execute a command substitution embedded
370
+ in a repository name. The form is now `rm -rf -- '<path>'`; `--` additionally stops a path
371
+ beginning with `-` from parsing as options.
372
+
373
+ - A pid in a lock's metadata is now required to be a positive integer within the range
374
+ `process.kill` accepts. `0` and negative values are
375
+ process-_group_ selectors for `process.kill`, so metadata carrying either made the liveness probe
376
+ answer for a whole group — reporting a long-gone owner as present, and keeping its lock
377
+ unreclaimable for the rest of its window. A value past that range is worse still: Node rejects it
378
+ with a `TypeError` rather than an errno, which the probe read as "not gone, therefore present".
379
+ Such metadata is now reported as malformed instead of acted on.
380
+
381
+ - A lock is no longer taken away from a holder that is still working, however long the work takes.
382
+ Locks were judged by a fixed ten-minute age: past it, a waiter treated the lock as abandoned and
383
+ stole it even when its owner was demonstrably alive and mid-operation. Since the per-ref lock is held across a whole clone or
384
+ fetch, any repository large enough to take ten minutes could end up with two processes running
385
+ `checkout -B` / `reset --hard` / `clean -fd` against the same directory.
386
+
387
+ A holder now renews a lease while it works, so a lock is abandoned when its process is definitely
388
+ gone **or** its lease has expired — never merely because the work took a long time. "The pid still
389
+ exists" does not override an expired lease, which is what keeps a recycled pid from stranding a
390
+ lock forever.
391
+
392
+ The same constant was also too long at the other end: a crashed `sync` or `add` left its lock
393
+ behind and blocked the ref for the full ten minutes. An abandoned lock is now reclaimable after
394
+ two minutes rather than ten.
395
+
396
+ Two limits are worth knowing. A holder whose event loop is stopped for longer than the lease — a
397
+ suspended process, a sleeping machine — cannot renew, and is stealable after two minutes where it
398
+ used to take ten; in practice a sleeping machine suspends every refs process on it, and the
399
+ pending renewal fires on resume. And a lock written by an older CLI carries no lease and is still
400
+ judged by the ten-minute rule it was written under, so upgrading never dispossesses a running
401
+ older process. The reverse does not hold:
402
+ an older CLI reads no lease, so it can still take a lock from a live current holder once ten
403
+ minutes pass. Holds longer than that during a rolling upgrade are not protected.
404
+
10
405
  ## [0.10.0] - 2026-08-13
11
406
 
12
407
  ### Added
@@ -514,7 +909,9 @@ trusted-publishing pipeline end to end.
514
909
  installed git hooks.
515
910
  - Agent skill (`skills/refs/`) documenting the investigate/add/maintain workflows.
516
911
 
517
- [Unreleased]: https://github.com/kaisers-io/refs/compare/v0.10.0...HEAD
912
+ [Unreleased]: https://github.com/kaisers-io/refs/compare/v0.12.0...HEAD
913
+ [0.12.0]: https://github.com/kaisers-io/refs/compare/v0.11.0...v0.12.0
914
+ [0.11.0]: https://github.com/kaisers-io/refs/compare/v0.10.0...v0.11.0
518
915
  [0.10.0]: https://github.com/kaisers-io/refs/compare/v0.9.0...v0.10.0
519
916
  [0.9.0]: https://github.com/kaisers-io/refs/compare/v0.8.3...v0.9.0
520
917
  [0.8.3]: https://github.com/kaisers-io/refs/compare/v0.8.2...v0.8.3