npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mento-protocol/issues

v0.2.0

Published

GitHub ref-backed claims (compare-and-swap mutex with an opt-in lease), a bounded gh runner, and procedural-marker byte contracts.

Readme

@mento-protocol/issues

Ref-backed claims for coordinating automated agents over a GitHub repository without a shared server: a compare-and-swap mutex with an opt-in lease, a bounded gh subprocess runner, and the procedural-marker byte contracts agents post on pull requests.

One Git reference per claimed item is the sole authority: one per pull request under refs/mento-claims/v1/pr/<number>, one per issue under refs/mento-claims/v1/issue/<number>. The commit that reference points at is both the state and the fencing token, and GitHub's updateRefs mutation makes taking it atomic.

Plain ESM, Node >= 22.12, zero runtime dependencies, no build step: the published bytes are the reviewed bytes.

See docs/design.md for the state machine, payload schema, transition table, conflict classification and the safety invariants.

Install and run

Nothing installs this package. Consumers pin the exact version in policy and spawn the CLI:

pnpm --config.ignore-scripts=true --package=@mento-protocol/[email protected] \
  dlx mento-issues claims read --pr 872 --config .github/dependabot-prep-policy.json

Two details of that command line are load-bearing, both measured against pnpm 10.34.5:

  • --package=<name>@<version> … dlx <binary>, not dlx <spec> <binary>. The shorter pnpm dlx @mento-protocol/[email protected] mento-issues … form passes mento-issues to the CLI as its first positional argument, where it is an unknown command.
  • --config.ignore-scripts=true, not --ignore-scripts. The latter is not a dlx option. pnpm 10 already ignores dependency build scripts by default; the flag makes that independent of the host's .npmrc.

As a library, in a workspace that can depend on it:

import { acquireClaim, guardChild } from "@mento-protocol/issues/claims";

Subpaths: /claims, /gh, /markers, /cli, /testing, and /fixtures/comment-marker-vectors.json.

Commands

Every command emits exactly one JSON document on stdout, on success and on failure alike. claims guard is the single documented exception: its stdout belongs to the child it spawns, so its own documents go to stderr.

Global flags

| Flag | Meaning | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | --config <path> | the policy or package config; required for every claims command | | --json | the only output mode; accepted for explicitness | | --dry-run | plan and print; performs no write and no label mutation, though label reconcile still reads the labels it compares, and refuses every input the write would have refused | | --timeout-seconds <n> | per-gh wall-clock timeout, default 60; more than 0 and at most 86400, because a value outside that arms no timer at all | | --quiet | drops guard's pre-spawn verdict line; no effect elsewhere | | --host, --runtime, --login, --agent | identity overrides | | --state <path> | state-file root | | --run-id <id> | required by renew, release, verify, guard, family release and slot clear; rejected by claim, takeover and family claim, which generate their own; adopt resolves it from the flag, from --from-state or from this host's state entry; read, list, label and doctor never need it | | --pr <n> / --issue <n> | the claimed item; --prs/--issues on list and the family commands. The loaded config's claims.profile decides which of the pair is legal, and the other one, both of them, or neither is exit 2 before any network call |

The profile picks the flag. Both --pr and --issue are in the grammar of every command that names an item, and the loaded configuration decides which one this run takes: --pr under an issue config is

The loaded config selects the issue profile, so claims claim takes --issue, not --pr

exit 2, refused before the login read and before the first write. A sweep that types --pr from muscle memory must never claim the wrong namespace quietly. claims list names no item when it is listing the whole namespace, but at most one of --prs and --issues still holds.

Gated flags need allowOverrides: true in the loaded config, or they exit 3: --ttl-minutes, --grace-minutes, --min-remaining-seconds, and --now <iso> which additionally requires MENTO_ISSUES_ALLOW_CLOCK_OVERRIDE=1.

--now is refused outright (exit 2) on claims guard, and on claims verify whose --gate is mandatory, even with both permissions. Guard reads the runtime clock for the fence proof's remainingMs and for the --if-due renew that keeps that proof true, so a supplied instant forges the fence and disables the renew timer at once — the same lie --dry-run is already refused for. Read commands, advisory gates and dry-run planning keep the flag.

allowOverrides buys different lease numbers, never a smaller safety budget: the merged lease runs through the same floors the config document does, so --grace-minutes 0 or a --min-remaining-seconds that leaves the renew window uncovered exits 3 exactly as the equivalent policy would.

claims

Below, --pr|--issue <n> means "the number flag the loaded profile selects".

| Command | Flags | Writes | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------ | | read --pr\|--issue <n> | — | none | | list | [--stale] [--prs\|--issues 872,880] [--concurrency <n>] | none | | claim --pr\|--issue <n> | [--run-id-prefix <slug>] [--no-takeover] [--set k=v]… | commit, reference, label | | renew --pr\|--issue <n> --token <oid> --run-id <id> | [--if-due] [--set k=v]… | commit, reference | | takeover --pr\|--issue <n> --supersedes <oid> | [--run-id-prefix <slug>] [--set k=v]… | commit, reference, label | | release --pr\|--issue <n> --token <oid> --run-id <id> | [--outcome <slug>] | commit, reference, label | | verify --pr\|--issue <n> --token <oid> --run-id <id> | [--gate <g>] [--advisory] [--min-remaining-seconds <n>] (gated) | none | | guard --pr\|--issue <n> --token <oid> --run-id <id> --gate <g> -- <argv…> | [--no-renew] [--advisory] [--report <path>] | renew commits while the child runs | | adopt --pr\|--issue <n> | (--candidate <oid> --operation-id <id> --run-id <id> \| --from-state) [--action …] [--parent-lock <oid>] | none | | family claim --prs\|--issues 872,880,881 | [--run-id-prefix <slug>] [--set k=v]… | commits, references | | family release --prs\|--issues … --tokens … --run-id <id> | [--outcome <slug>] | commits, references | | label ensure | [--color <hex>] [--description <text>] | labels only | | label reconcile --pr\|--issue <n> | [--apply] | labels only, and only with --apply | | slot clear --pr\|--issue <n> --run-id <id> | [--dry-run] | one host-local file | | doctor | — | none |

The two namespaces are independent. One repository may run a pull-request sweep and an issue sweep at once: they write different references, different state-store entries (pr-872.json beside issue-872.json), different host-local guard slots and different labels, and nothing is shared but the code. They also never coordinate — see "Issues and pull requests share one number space" below.

claim takes over automatically. Against an UNLOCK it acquires; against a LOCK whose lease has expired past its grace it takes over in the same process and reports status taken-over, exit 0. A live LOCK is exit 10 contended carrying eligibleAt; our own LOCK is exit 10 already-held. --no-takeover opts out and returns exit 11 expired with the oid a takeover would need.

There is no heartbeat. Liveness is renew --if-due, which writes only when renewAfter has passed, and guard calls it on its own timer.

Release with the token the reference is at. Repeating a release that landed is exit 0 already-released, and so is a release whose claim this run has since released and re-acquired: that later LOCK carries the UNLOCK it came from, which only a completed release writes. A token one or more renewals old is not that case — the reference is still LOCK — so it is exit 16 stale, naming the token to release with, and nothing is unlabelled or cleared. Every renew prints the rotated token in next, and that is the one to release with.

A release reconciles the label; it never removes it blindly. A successor can acquire between the compare-and-swap that writes the UNLOCK and the label call that follows it. release and family release therefore reconcile each released pull request's label against the reference as it is when that call runs — the same comparison label reconcile --apply makes — so the label goes only when the reference is at UNLOCK or absent, and a successor's LOCK keeps it. The reported label.changed is what the reconcile actually did.

An identifier is never a credential. --run-id (and MENTO_CLAIM_RUN_ID), --run-id-prefix, --host, --runtime, --login, --agent and every --set value are refused when they carry a credential shape. Every one of them is recorded in the claim payload and printed in reports, and the claim-id grammar would otherwise accept a ghp_… as a perfectly good run id. The rule is enforced by the claims layer, not by the argument parser, so a program using this package as a library gets it too: createClaimContext, resolveOwner and every transition refuse the same values. The environment rules are enforced the same way — the label calls included, so projectClaimLabel, ensureClaimLabel and an applying reconcileClaimLabel refuse to write from GitHub Actions or from an unapproved cloud session, exactly as the commands that wrap them do.

The same rule covers what a config persists and what a transition is called with. Every string in the loaded document is checked, because claims.kind reaches payload.kind and the commit message of every claim, and claims.author the commit identity: a credential anywhere in it is exit 3 at load. And agent, claimId and operation supplied in a transition's metadata are checked before it writes, because they are copied into the LOCK payload.

label reconcile writes only with --apply. Without it the command compares the label against the ref and reports the difference, so it is a read: it runs under GITHUB_ACTIONS, in a cloud session without allowCloudWriters, and without a resolvable runtime or a login read. With --apply it is a write and every one of those restrictions applies. It changes nothing when the reference cannot be read: a timeout, a permission refusal or an unreadable payload answers status: "unknown" with a non-zero exit — 20 for a transport, 16 for a reference this package proved unreadable — because a read that failed is not evidence that a claim is gone. It re-reads the reference immediately before either mutation and decides again on what it says then: a claim acquired while the command was comparing keeps its label, and a claim released in that window is not given one. Neither direction is this command's to assume — it needs no token and no run id, so the reference it read may belong to anybody. That narrows the window rather than closing it, since GitHub has no compare-and-mutate for labels, and the next reconcile corrects whatever this one gets wrong.

label ensure --color is checked here, not by GitHub. A colour that is not six hexadecimal digits is exit 2 and costs no round trip. It used to resolve a login, be refused by the create and by its one retry, and report both refusals as warnings beside status: "ok" and exit 0. One leading # is accepted and removed: GitHub's label API carries the six digits alone, so #ff0000 would fail the create and, on an existing label, report a colour mismatch against a label that matches. ##ff0000 is refused like anything else.

A dry run refuses what the write would refuse. --dry-run runs the transition's own input checks before it plans, so an unusable --set value or --run-id-prefix — on claim, takeover, renew or either family command — is the same refusal, with the same message, whether or not the run goes on to write. family claim validates membership first as well, so --prs 872,872 refuses instead of printing two plans for one pull request. claims.verifySubjectKind is part of that set: a plan makes the same issue read and answers the same exit 10 not-eligible for a pull-request number. A plan that answered ok for an input the run would have rejected was worse than no plan.

A dry run leaves nothing behind. No ref is written, no label is added or removed, and the host-local state file is not touched either: guard --dry-run is refused before it reserves a slot, records a guarding entry or replaces a --report file, and adopt --dry-run names the entry it would write without writing it. Reads still happen, and one of them is a label read: label reconcile lists the pull request's labels to report the difference it would apply. Nothing under --dry-run mutates a label.

A plan predicts the run's answer, including exit 0. Planning a release whose UNLOCK is already at the head reports status: "already-released" and what it would do — nothing — rather than a refusal, because that is exactly what running it answers, and repeating a release is the case its idempotence exists for.

--set writes claim metadata, restricted to the profile's keys. The pr profile records lastPushedHead, reviewRequestedHead and summaryCommentUrl; the issue profile records branch, pullRequest and lastCommentUrl. They survive both renew and takeover, and that is the whole point of them: they stop a new owner re-pushing a head, re-requesting a review, re-creating a branch, re-opening a pull request or re-posting a comment the previous owner already handled.

pullRequest is a decimal string, not a number, because every --set value arrives as a string; the literal null writes a null. branch is free single-line text of at most 120 characters and never a credential: the profile itself refuses a value that looks like a GitHub token, so acquireClaim, renewClaim and takeoverClaim refuse one before the first write rather than only the --set grammar refusing it. summaryCommentUrl and lastCommentUrl are https://github.com/ URLs of at most 300 characters under that same rule: a credential in the query string, such as ?token=ghp_…, is refused there too, because the host prefix and the length alone do not exclude one.

list --stale selects LOCKs whose lease has expired and reports the claimed item's state — the abandoned-claim-on-a-merged-PR case. Under the pr profile that is state, draft and merged. Under the issue profile it is state, stateReason and a pullRequest boolean, because GitHub's issues endpoint serves pull requests too and an issue claim standing on a pull-request number is worth seeing. Recovery is the ordinary path: claim (which takes over), then release --outcome skipped.

Holding an issue claim for hours

A multi-hour hold is a renew cadence, never a long TTL — and it ends. Every expiry is clamped to claimedAt + maxTtlMinutes, so the hold is hard-capped at maxTtlMinutes from the acquire and no renew reaches past it. MAX_TTL_CEILING_MINUTES is 360 and MAX_GRACE_MINUTES is 60, so six hours from the acquire is the longest hold any valid policy can produce. A run that needs longer releases and claims again.

That cap is also why ttlMinutes must sit below maxTtlMinutes. A policy whose TTL equals its ceiling reaches the ceiling on the very first acquire, and every later renew then returns the same expiresAt — a renew that renews nothing. The recommended shape leaves the renew room to work:

{
  "ttlMinutes": 120,
  "renewMinutes": 60,
  "graceMinutes": 60,
  "minRemainingSeconds": 1800,
  "maxTtlMinutes": 360
}

An hourly renew --if-due from cron moves the expiry an hour further out each time, until the ceiling binds. The claim becomes takeable at

min(lastRenew + ttlMinutes, claimedAt + maxTtlMinutes) + graceMinutes

— three hours after a renew early in the hold, and never later than seven hours after the acquire whatever the cadence. Size a crash-recovery window from that formula, not from lastRenew + ttl + grace.

Two things happen as the ceiling approaches, and a sweep has to expect both. In the last minRemainingSeconds before it — thirty minutes, with the shape above — claims verify and claims guard exit 15 renew-required, and the renew they ask for cannot help, because the expiry is already at the ceiling; the run stops and releases rather than retrying. At the ceiling itself claims renew exits 16 stale, naming the ceiling and the acquire it is measured from. A cron host that sleeps loses the claim, correctly, and the recovery is the ordinary path: the next claim takes over automatically.

Every renew rotates the token, so re-read claim.token from each document's next rather than reusing the one the acquire printed.

One run id per repository

generateRunId runs only inside an acquiring transition, so one run id cannot span repositories. A sweep across three repositories holds three claims under three run ids with three configuration documents, because repository names exactly one. family and guard are single-repository primitives: a family shares one context and one repository, and a guard's repeated pairs must all be in it.

Use --run-id-prefix issue-sweep so the three are recognizable as one sweep, and keep the {repo, issue, token, runId} tuple yourself — nothing in this package holds it for you.

Issues and pull requests share one number space

GitHub gives issues and pull requests one sequence, and repos/{owner}/{repo}/issues/{n} serves both. So refs/mento-claims/v1/pr/872 and refs/mento-claims/v1/issue/872 are two independent mutexes over at most one real item, and a ref name carries a number and no kind — nothing in the claim layer can tell the difference.

Two things surface it. claims list reports a pullRequest boolean per issue entry, after the fact. And claims.verifySubjectKind: true refuses it before the first write: claim, takeover and family claim read the issue and exit 10 not-eligible when the number is really a pull request. The refusal carries CLAIM_SUBJECT_KIND and recoverable: false: exit 10 asks the caller to skip the item, and no retry turns a pull-request number into an issue. The read is part of each command's own input checks, so it happens under --dry-run too and a plan never calls a pull-request number claimable. A family reads its members in claim order and stops at the first refusal, so nothing is written at all. It is false by default because it costs a round trip on the hot path; an issue policy should set it to true.

The check fails open: when the read itself fails — no gh, a 5xx, a revoked token — the claim is allowed and the run carries a warning whose stage is verify-subject-kind. A transport fault must not deny a claim the operator is entitled to, and claims list still reports the hazard afterwards. The key is inert under profile: "pr", where the endpoint already names the kind.

--outcome accepts ready-for-maintainer-decision, needs-decision, blocked, skipped, budget-exhausted, family-rollback, rehearsal and completed (the default). Release whenever the run stops acting on the item, terminal or not: work in progress stays in the checkout and the next writer re-claims.

claims guard

Guard is how a claim survives a long publishing command:

mento-issues claims guard --config <cfg> --pr 872 --token <oid> --run-id <rid> \
  --gate push -- git -C <worktree> push \
  --force-with-lease=refs/heads/<branch>:<observedSha> origin HEAD:refs/heads/<branch>

It verifies, spawns with shell: false and detached: true, renews on a timer for the child's whole lifetime, and — for a mandatory gate — kills the child's process group (SIGTERM, then SIGKILL after five seconds) and exits 13 if a mid-flight renew finds the claim lost. The group, not just the child: a git push runs a pre-push hook that spawns trunk check --all, so killing git alone leaves the hook tree writing. The SIGKILL is delivered even when the direct child exits first — git and node die on the SIGTERM in milliseconds, and the survivor the escalation exists for is precisely the one that ignores it — so guard sees the grace out before it returns.

The renew tick is half the safety window — minRemainingSeconds plus graceMinutes, capped at renewMinutes — because that window, not the renew period, is how soon the claim can be taken from us after a positive verdict.

One tick runs at a time. A tick slower than the interval would otherwise overlap the next one, and both would renew the same lease: the first rotates the token, and the second's compare-and-swap then fails against a token that no longer exists, which guard would read as a lost claim and answer by killing a child whose claim this run still held. An overlapping tick is skipped, and it enforces the local lease deadline before it returns.

The lease deadline has its own timer, separate from the renew tick and from the scheduler a caller may inject. It reads no reference and starts nothing, so it keeps working while a renew is parked inside a transport call that never answers — which is exactly the case a deadline exists for.

A renew that fails for any other reason — a timeout, a 5xx, a revoked credential, a partition — is not a lost claim, so the child runs on, but it is no longer proven: that entry reports held: null and reason: "unverified" rather than reprinting the spawn-time verdict, and the proven lease becomes a local deadline. Once the clock reaches expiresAt - minRemainingSeconds — the same line the mandatory verdict applied before the spawn — guard kills the child with killedBy: "lease-expired" and exits 13. No network is needed for that check, which is the point: a transport that stopped answering cannot move the instant another run becomes free to publish.

If guard itself is signalled (SIGINT, SIGTERM, SIGHUP) it forwards the signal to that group and exits 3, with killedBy: "guard-<signal>" in the report. A library caller's AbortSignal stops it the same way, reported as killedBy: "guard-aborted" and status: "guard-aborted": the abort reaches the whole process group, not the direct child alone, so a detached grandchild cannot outlive it. An abort that arrives before the child starts stops there instead of waiting: the pre-spawn verification can sit in a gh call for as long as the transport timeout allows, and an abort during it now ends the run with spawned: false rather than spawning the command afterwards. That covers the pre-spawn repair as well: guard never starts a renew for a run whose caller has already withdrawn it. detached takes the child out of the terminal's foreground group, so without that forwarding a Ctrl-C would kill guard and leave the child publishing under a lease nothing renews. The forwarders are armed before the child is spawned, and a signal that arrives while the spawn is in flight is applied the instant the child exists. The same detached gives the child no controlling terminal, so a guarded command must be non-interactive: a credential prompt fails rather than hanging, which is the right failure for an unattended run but is worth knowing before guarding something by hand.

| Gate | Kind | Behaviour when the claim is not held | | --------------------------------- | --------- | ------------------------------------------- | | push, review-request | mandatory | refuse to spawn; exit from the verify table | | wait | advisory | print the verdict and spawn anyway | | summary-comment, inline-reply | advisory | print the verdict and spawn anyway |

That table is the default, not a constant: requiredBefore and advisoryBefore in the loaded config decide it, and promoting a purpose there really does gate the write.

Guard writes its report to stderr as one JSON line before spawning and one after the child exits; --report <path> also writes the final one to a file. It forwards the child's exit code unchanged, unless --advisory is set, which forces exit 0 (and is refused on a mandatory gate). --advisory covers the child's outcome, never a guard that was terminated: an abort or a forwarded SIGINT/SIGTERM/SIGHUP is exit 3 with it exactly as without it, and a child that never started — a spawn that failed natively — stays exit 2 spawn-failed rather than being reported as a success. For the git and gh commands this package is designed to guard, exit codes 10–16 are never the child's, so a guard exit in that range is guard's own verdict; guard will run any argv, though, so for an arbitrary command read status and killedBy from the report line rather than the code alone.

A renewal that was in flight when the child exited is finished, not abandoned. A read still running is dropped at once; a compare-and-swap that has already been issued is not, because GitHub may have applied it and lost the answer — it runs to a confirmed token or to an unknown outcome. A confirmed one is what the final report names. An unknown one is reported under unresolved with its candidate and the adopt line that resolves it, and recorded in the state file, exactly as every other unknown outcome is; it is never dropped just because the run was on its way out.

Repeated --pr/--token pairs under one --run-id guard a whole family. A second live guard holding the same --run-id for the same item exits 3: guard is the publish gate, so two of them under one run id would each verify held and each spawn a publishing child.

The guard slot, and claims slot clear

Every --pr/--token pair is validated first — a positive number, a token, and no number named twice — because everything below creates files: a pair naming 0 used to reserve a slot for each valid pair and then fail deriving its ref, leaving reservations only claims slot clear could remove.

Before it spawns, guard then creates one host-local file per guarded pair — <state dir>/<owner>__<repo>/pr-<n>.guard-<digest of run id>.json, with owner and repository lowercased so one repository has one directory however it is spelled — with an exclusive create. That create is the whole reservation: exactly one of any number of racing guards makes the file, and every other one exits 3. The holder removes it when the child exits, and only its own: the nonce inside is read back through a descriptor opened before the unlink, and a file carrying anyone else's nonce is left alone with a warning. That "only its own" holds while no slot clear runs beside it — the read and the unlink are two operations, so a clear in between lets a successor create a slot the departing holder then removes. Same residual as slot clear below, and it closes the same way.

The reservation itself is all-or-nothing: a write that errors, a write that reports fewer bytes than the document, or a failing fsync each remove the file they just created and refuse, so guard never spawns behind a half-written slot. That cleanup unlinks only when the path still names the file the exclusive open returned (fstat against lstat, by dev and ino); anything else is left alone and named in the refusal. What no code can cover is a kill between the exclusive create and the write, which leaves a zero-length file.

Manual recovery is for every slot slot clear cannot prove dead, not only that one. That means a document it cannot parse (zero-length from the window above, or otherwise truncated or corrupted), a pid that is not a positive safe integer, and a probe that answers anything other than ESRCH — the unreadable, invalid-pid and unprovable/held statuses in the table below. In each case, confirm that no guard of that run is alive, then remove the file yourself.

Guard never takes a slot over, so a guard that was killed outright leaves a slot that refuses every later guard of that run, whatever state the recorded process is in. That is deliberate. Node offers exclusive create, link, rename and unlink, and none of them compares before it acts — there is no compare-and-rename and no compare-and-unlink — so every "inspect the holder, then take the file" reclaim has a window between the two steps that an unbounded pause can stretch until two guards hold one slot. Four concurrent processes were enough to demonstrate it against each design that tried.

Recovery is one explicit command, which the refusal prints ready to run — with that invocation's own --config, and its --state whenever the store is not on the host's default root:

mento-issues claims slot clear --config <cfg> --pr 872 --run-id <rid>

It removes a slot only on positive proof of death: kill(pid, 0) answering ESRCH, and nothing else. Every other outcome exits 3 under a status that says which:

| Status | Meaning | | ------------- | ------------------------------------------------------------------- | | held | the signal succeeded, or EPERM — the process exists | | invalid-pid | the document's pid is not a positive safe integer; never probed | | unprovable | any other errno: the host could neither reach it nor prove it dead | | unreadable | the document cannot be parsed, so there is no pid to probe | | failed | the holder is dead but the unlink itself failed; the error is named | | absent | there is no slot; exit 0 |

"Not alive" is not proof of death, and this is the one place where the difference decides whether a file is deleted. --dry-run reports what it would remove. It reaches no network.

The residual, stated plainly: run beside a live guard of the same run id on the same host, slot clear can displace that guard, because a liveness check and an unlink cannot be made one operation. The rule that closes it is procedural — one guard per run at a time, and clear a slot only after confirming that no guard of that run is alive. The slot is defence in depth against one run accidentally starting two guards; the reference's compare-and-swap and the exact-head --force-with-lease push are the safety controls, and neither depends on this file.

markers and config

| Command | Flags | Notes | | ------------------------------------- | --------------------- | ------------------------------------------------------------- | | markers build --input <job.json> | [--out <body.txt>] | builds the comment body and its marker | | markers verify --input <check.json> | — | exit 2 on a mismatch, so a caller cannot read 0 as "verified" | | markers summary --input <job.json> | [--out <block.txt>] | the two-line PR summary block | | markers vectors --out <path> | [--check] | regenerates the fixture; --check exits 3 on drift | | config show / config validate | — | prints the normalized config |

markers summary takes { pr, claim, ownerRunId, operator, supersedes? } and returns v1Line, v2Line and block (the two joined by one LF): the unchanged discovery line <!-- mento-dependabot-preparation:v1 -->, then <!-- mento-dependabot-preparation:v2 pr=<n> claim=<40hex> run-sha256=<64hex> operator-sha256=<64hex> [supersedes=<40hex>] -->. run-sha256 is sha256(utf8(ownerRunId)) — the run id must match the claim-id grammar, so an empty or malformed one is MARKER_RUN_ID_INVALID rather than a digest of bytes that name no run — and the operator digest covers the exact bytes {"id":<n>,"login":"<l>","type":"User"}. It is a command because the consuming skill only ever runs this CLI and would otherwise hand-hash both.

Every job file is key-allowlisted: an unknown key in a summary, build or verify job — or in a build job's nested root — is refused by name with exit 2. supersedes is the only optional field in the set, and it is the one that records a cross-login takeover, so a misspelling has to fail rather than drop silently.

Two places the marker encoders are stricter than v1's written contract, by design: the visible body must not end a line with a tab either (v1 names only spaces), and the operator object must carry exactly id, login and type — pick those three out of a gh api user response rather than pasting it whole, or the build fails with MARKER_OPERATOR_INVALID.

markers build and markers verify accept the global --config; when one is given, a job whose schema revision disagrees with the policy's markerRevision is refused with exit 3. markers summary reads the other policy field, reporting.prCommentClaimMarkerSchema: the loader accepts only mento-dependabot-preparation:v2 for it and defaults to that when the reporting section is absent, so a policy that names anything else is refused (exit 3, CLAIM_CONFIG_SUMMARY_MARKER_SCHEMA) instead of quietly getting v2 bytes. Nothing else in reporting is read here; prCommentMarker and the rest belong to the consuming skill.

build, summary and vectors also honour the global --dry-run: the bytes are built and reported exactly as they would be, --out is named in out, and the file is left alone. The document then says dryRun: true and written: false, which is how a planning run is told apart from a writing one. Without the flag, written: true records that the file was replaced.

Exit codes

| Exit | status | Action | | ---- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | 0 | acquired renewed not-due taken-over released already-released held ok | continue | | 2 | usage | fix the command | | 3 | config | stop and report to the operator | | 10 | contended already-held not-eligible clock-skew family-aborted | skip this pull request (or family) this run | | 11 | expired | run takeover --supersedes with the printed oid | | 12 | unknown-outcome | do not retry; run adopt | | 13 | superseded | stop publishing this PR and treat work in flight as forfeit | | 14 | not-held | renew with the printed token, or stop | | 15 | renew-required | renew --if-due, then retry once | | 16 | stale | stop and report to the operator | | 20 | transport | retry with backoff | | 21 | permission | stop and report to the operator |

The coarse rule, which a calling agent can follow without the table:

0 proceed; 10/11/14/15 act as printed; 12 run adopt; 13 stop publishing this PR
and treat work in flight as forfeit; 3/16/21 stop and report; 20 retry.

The pull-request wording in rows 10 and 13 and in the coarse rule is deliberate, and the issue profile inherits it unchanged. Consuming skills copy those strings verbatim into their own instructions, so rewording them to say "item" would silently invalidate every copy. Read "this pull request" as "this claimed item" under the issue profile.

A refusal is not an ambiguity. A write the server refused (permission, exit 21) and a missing gh (config, exit 3) answer the request rather than leaving it open, so the compare-and-swap loop stops on them and reports them as themselves. It used to spend its attempts, find the reference unmoved each time, and report exit 12 unknown-outcome — sending the operator to adopt to look for a commit that was never written. A credential that may read the reference but not write it is exit 21 for that reason, whether the refused write was an acquire, a renew, a release or the first create of a reference, and whether or not the reconciling read that follows can answer at all. A refused renew matters most: read as superseded, it told a guard its claim was gone, so the guard killed the child and called the work in flight forfeit.

Rate limiting is not a refusal. GitHub answers a primary or secondary rate limit with 403, exactly as it answers a credential it will not accept, and the two ask for opposite things. A rate limit is transport at exit 20 — retry with backoff — and carries GitHub's own retry-after when the response sent one; only a real credential or session problem is exit 21.

A family aborts with family-aborted at exit 10 — skip the family this run — except when the rollback left a LOCK behind: that is stale at exit 16, the operator's row, because only a compare-and-swap by hand clears it. Anything ambiguous outranks both: a member whose LOCK compare-and-swap ended unknown, a rollback release whose UNLOCK ended unknown, and a family release in which any member's UNLOCK ended unknown are all unknown-outcome at exit 12, with every ambiguous member listed under unresolved, each carrying the candidate, the state file it was recorded in and the adopt line that resolves it. Every other family release failure keeps the status and exit code the same failure would have on a single release, so a member this run does not hold is not-held at exit 14 either way. Members that did release are still listed in released.

A family claim follows the same rule now. family-aborted is a verdict about a race, so a member that failed for a reason nobody raced — a credential that may not write, a transport that stopped answering — keeps the classification a single claim would give it (exit 21, exit 20), provided the rollback released every other member cleanly. And a member the rollback could not release keeps its LOCK, so its label is projected before the failure is reported rather than left off an item that is held.

Exit 3 also covers two faults that are neither the command's nor the mutex's: gh missing from PATH, and a claim reference that reads as absent after three create-from-absent compare-and-swap attempts — a repository fault. A create the server refused is not that case: it reports the refusal itself, exit 21. Both stop the run and reach the operator rather than inviting a retry. A guard that was itself signalled exits 3 for the same reason.

Two classifications are worth stating because they used to be wrong. Losing the create race — the winner initialized the ref and had already acquired by the time the loser looked — is exit 10 contended, an ordinary lost race, not the unclassified conflict that surfaced as exit 1. And a transport failure on the read that precedes a release is exit 20 transport: that read runs before any compare-and-swap, so a timeout or a 5xx there changed nothing and the caller still holds its claim. Exit 16 stale is reserved for a reference this package proves it can no longer release from.

A payload this profile cannot read is exit 16 from every command (changed in 0.2.0). claims read, claims renew and claims list always answered exit 16 stale on such a reference. claims claim and claims takeover read the same head through a raw read, so the parse refusal reached the CLI as a bare CLAIM_CONFLICT the exit table did not list, and they answered exit 1 usage — "fix the command", which names a fix no caller can make. They now answer exit 16 stale, "stop and report to the operator", like every other command on that reference. The message text and err.code are unchanged; the exit code, the status slug and the advice line are not. A caller that branches on exit 1 from claim or takeover — nothing in this repository does — must read 16 instead. At the library level the error is a ClaimRefInvalidError carrying refInvalid === true on the error itself.

Threat model

The claim reference coordinates cooperating writers. Both halves of the fencing identity are public: claims read prints the token and the run id, and the reference payload carries ownerRunId to anyone who can fetch the repository. renew, release, verify and guard all accept --run-id, so a second process with repository Contents write that supplies another run's identity can renew that run's claim and rotate its token.

What the design does guarantee is that no process acquires a claim it did not itself acquire — the run id is generated inside acquireClaim and cannot be passed to claim, takeover or family claim — and that every LOCK-producing transition is an exact-beforeOid compare-and-swap, so two acquirers cannot both win. That is unforgeability against accident and against a racing peer, not against a writer that deliberately borrows a published identity. Binding possession to a secret the reference only stores a digest of would close the gap; 0.2.0 does not do it.

The --config schema

Two document schemas are accepted: the package's own mento-issues-config:v1, and the consumer policy dependabot-prep-policy:v4, from which repository and coordination.claims are read.

Required in the claims block:

| Key | Value | | -------------------------------------------- | ---------------------------------------------------------------------------- | | schema | mento-claims-config:v1 | | profile | pr or issue (see below) | | namespace | a refs/… prefix | | scopeTemplate | the namespace plus /{pr} or /{issue}, matching the profile, exactly once | | label | a GitHub label name, or null | | package | { "name": "@mento-protocol/issues", "version": "0.2.0" } | | ttlMinutes, renewMinutes, graceMinutes | on a lease-capable profile only |

Optional, with defaults:

| Key | Default | | ---------------------- | -------------------------------------------------- | | kind | mento-claim | | payloadVersion | 1 | | author | the profile's bot identity | | maxTtlMinutes | 360 | | minRemainingSeconds | 360 | | skewToleranceSeconds | 300 | | markerRevision | v2 | | requiredBefore | ["branch-push", "review-request"] | | advisoryBefore | ["summary-comment", "inline-reply", "long-wait"] | | allowOverrides | false | | allowCloudWriters | false | | verifySubjectKind | false | | command | null |

Every validation failure exits 3 before any network call. The arithmetic rules are renewMinutes * 2 <= ttlMinutes, ttlMinutes <= maxTtlMinutes <= 360, 1 <= graceMinutes <= 60, minRemainingSeconds >= 30, minRemainingSeconds * 1000 < renewMinutes * 60000, and minRemainingSeconds * 1000 + graceMinutes * 60000 >= renewMinutes * 60000. That last rule is guard's: a claim becomes takeable minRemainingMs + graceMs after a positive verdict, and guard's renew tick has to fit inside that window. The same rules apply to the lease the gated flags produce.

author.name and author.email are held to the rule the transport enforces on a commit: non-empty, single-line, no surrounding whitespace, at most 120 characters. They are checked at load, so a policy typo is exit 3 before anything runs rather than a transport fault part-way through an acquire.

package.version must be an exact major.minor.patch: there is no minimumVersion, no range and no integrity digest, because npm registry immutability plus an exact version is the pin. package.name must be the package that loaded the document — a name mismatch is a hard refusal, while a version difference is only a warning, because the exact pin is enforced where the wrapper spawns pnpm --package=<name>@<version> dlx rather than here.

requiredBefore and advisoryBefore are the real mandatory/advisory table: they must together name every fence purpose exactly once, and verify and guard use them. markerRevision (and the optional top-level markers.revision) must be v1 or v2, and markers build/markers verify refuse a job whose schema disagrees with it. gh.timeoutSeconds is the per-gh wall-clock default that --timeout-seconds overrides, and the loader bounds it exactly as the flag is bounded: a positive integer of at most 86400.

Two refusals exist for the mistakes a copied policy makes, both exit 3 at load:

  • CLAIM_CONFIG_SCOPE_TEMPLATE_TOKEN — the template carries the other profile's placeholder, as in profile: "issue" beside scopeTemplate: "…/issue/{pr}". Without it the generic count answers "must contain {issue} exactly once, found 0", which names the symptom and not the cause.
  • CLAIM_CONFIG_NAMESPACE_OVERLAP — the namespace equals, is a prefix of, or is prefixed by another configurable profile's default namespace. A profile: "issue" pointed at refs/mento-claims/v1/pr passes every other rule here and then puts two skills on one set of references, where the payloads fail closed one at a time as CLAIM_REF_INVALID — a reading that looks like a corrupt reference rather than the configuration that caused it.

Upgrading to 0.2.0. That second rule reads both directions, so it also refuses a profile: "pr" policy whose claims.namespace is a parent of the issue profile's default refs/mento-claims/v1/issue — for example refs/mento-claims/v1, or refs/mento-claims. Such a policy loaded on 0.1.0 and stops loading on 0.2.0, at exit 3, before any network call. Move it to a sibling namespace of its own (refs/mento-claims/v1/pr, which is the default, or any name that is neither a parent nor a child of another profile's) before upgrading. The production dependabot-prep-policy:v4 document is unaffected: refs/mento-claims/v1/pr is a sibling already.

The other 0.2.0 behaviour change is an exit code: claims claim and claims takeover on a reference whose payload the loaded profile cannot read answer exit 16 stale where 0.1.0 answered exit 1 usage. See Exit codes. It applies to the pr profile too.

profile: "issue-board" is a valid library profile — it is the executable proof that this package is a byte-for-byte drop-in for monitoring's mutex — but the configuration loader refuses it (CLAIM_CONFIG_PROFILE_UNSUPPORTED): its canonical scope needs a Project owner and number that no command line supplies. The configurable profiles are pr and issue.

dependabot-prep-policy:v3 is rejected by name, and so is a v4-shaped document that still declares the retired repository-wide coordination.lockPath without a coordination.claims block. Both directions fail closed: a half-rolled-out policy stops a run rather than silently downgrading it.

The JSON envelope

{
  "schema": "mento-issues-result:v1",
  "command": "claims.claim",
  "status": "acquired",
  "exitCode": 0,
  "dryRun": false,
  "repository": "mento-protocol/frontend-monorepo",
  "ref": "refs/mento-claims/v1/pr/872",
  "scope": { "repo": "mento-protocol/frontend-monorepo", "pr": 872 },
  "claim": {
    "token": "a1b2…5678",
    "parentUnlock": "3f0c…",
    "runId": "claude-code-mac-…",
    "host": "chapati-mbp",
    "runtime": "claude-code",
    "login": "chapati23",
    "claimedAt": "…",
    "startedAt": "…",
    "expiresAt": "…",
    "renewAfter": "…",
    "ttlSeconds": 1800,
    "graceSeconds": 300,
    "renewCount": 0,
    "supersedes": null,
    "metadata": {
      "lastPushedHead": null,
      "reviewRequestedHead": null,
      "summaryCommentUrl": null,
    },
  },
  "label": { "name": "dependabot-prep:claimed", "changed": true },
  "clock": { "offsetMs": 37, "budgetMs": 660000, "warn": false },
  "next": { "verify": "…", "renew": "…", "guard": "…", "release": "…" },
  "statePath": "…",
  "warnings": [],
  "error": null,
}

An issue claim is the same envelope with its own scope, ref and metadata, and with --issue in every generated line:

{
  "command": "claims.claim",
  "status": "acquired",
  "ref": "refs/mento-claims/v1/issue/4312",
  "scope": { "repo": "mento-protocol/monitoring-monorepo", "issue": 4312 },
  "claim": {
    "metadata": {
      "branch": "sweep/4312",
      "pullRequest": "1187",
      "lastCommentUrl": null,
    },
    // …every other claim field is unchanged
  },
  "next": {
    "renew": "mento-issues claims renew --config … --issue 4312 --token … --if-due",
  },
}

A failure document has the same shape and adds current, takeover and error { code, claimCode, message, recoverable, publicationBlocked, recovery, advice }. An unknown outcome (exit 12) fills error.recovery { candidate, lastKnownOid, doNotRetry, operatorText, inspect } and next.adopt, which is the whole self-service recovery: no operator needed. That line carries --run-id along with --candidate and --operation-id, because adopt proves a candidate is ours by the head's owner run id: run it as printed. Given neither --run-id nor --from-state, adopt falls back to the run id this host recorded for that number and, failing that, refuses with exit 2 rather than reporting a landed claim as superseded.

Every document reprints next with the current token, so the newest document always supersedes an older one.

Every generated line runs as written. A printed follow-up carries the globals that decide where it resolves — --config, --state when this run is not on the host's default state root, and any --host, --runtime, --login, --agent or --timeout-seconds the invocation supplied — and each value is POSIX single-quoted, so a path holding a space, a quote or a $VARIABLE reaches the CLI intact. Both paths are absolute: --config and --state are resolved once, before the config is loaded and before the store is built, so a line pasted into another directory names the same two files rather than whatever ./config.json happens to mean there. Anything the config file itself carries is left to the config, since the follow-up loads that same file. This covers next, the inspect line, the family guard line, and the adopt command inside error.recovery.operatorText: a line printed without --config exited 2 for the operator who followed it, and one printed without --state would reserve its guard slot in a different store.

Using this from a repository policy

A consuming repository carries the claims block inside its .github/dependabot-prep-policy.json:

{
  "schema": "dependabot-prep-policy:v4",
  "repository": "mento-protocol/frontend-monorepo",
  "workflow": { "skill": "dependabot-prep", "revision": "trusted-agent-v2" },
  "coordination": {
    "primitive": "github-ref-claims",
    "claims": {
      "schema": "mento-claims-config:v1",
      "profile": "pr",
      "namespace": "refs/mento-claims/v1/pr",
      "scopeTemplate": "refs/mento-claims/v1/pr/{pr}",
      "ttlMinutes": 30,
      "renewMinutes": 10,
      "graceMinutes": 10,
      "minRemainingSeconds": 360,
      "label": "dependabot-prep:claimed",
      "markerRevision": "v2",
      "requiredBefore": ["branch-push", "review-request"],
      "advisoryBefore": ["summary-comment", "inline-reply", "long-wait"],
      "allowOverrides": false,
      "allowCloudWriters": false,
      "command": ["pnpm", "dependabot:claim", "--"],
      "package": { "name": "@mento-protocol/issues", "version": "0.2.0" }
    }
  },
  "forbiddenActions": ["delete-claim-refs"]
}

command names the wrapper the playbook invokes. The wrapper validates the policy, splices --config <policy path> after the group and command words and before any further --, then spawns the pnpm … dlx line shown above with shell: false and inherited stdio, and exits with the child's code.

Four facts a consuming repository must agree with this package on:

  • Gate names. Policy branch-push is CLI --gate push; policy long-wait is --gate wait. The other three — review-request, summary-comment, inline-reply — are spelled the same on both sides.
  • Argument splicing. pnpm 10.34.5 forwards the first bare -- after a script name rather than stripping it, so a wrapper drops one leading -- and then splices --config before any remaining --. A guarded child's argv must reach guard intact.
  • A signal-killed child exits 3, never 128 + signal. Under the coarse rule 3 means "stop and report", which is the right reading of a wrapper whose child was killed.
  • Working directory. The playbook invokes the wrapper with pnpm --dir <main-tracking-checkout> dependabot:claim -- claims <command> …, and a guarded argv inherits that directory. Guarded git commands therefore name their own tree explicitly: git -C <pr-worktree> ….

An issue sweep carries a standalone mento-issues-config:v1 document instead, one per repository, because repository names exactly one:

{
  "schema": "mento-issues-config:v1",
  "repository": "mento-protocol/monitoring-monorepo",
  "claims": {
    "schema": "mento-claims-config:v1",
    "profile": "issue",
    "namespace": "refs/mento-claims/v1/issue",
    "scopeTemplate": "refs/mento-claims/v1/issue/{issue}",
    "ttlMinutes": 120,
    "renewMinutes": 60,
    "graceMinutes": 60,
    "minRemainingSeconds": 1800,
    "maxTtlMinutes": 360,
    "label": "issue-sweep:claimed",
    "verifySubjectKind": true,
    "requiredBefore": ["branch-push", "review-request"],
    "advisoryBefore": ["summary-comment", "inline-reply", "long-wait"],
    "allowOverrides": false,
    "allowCloudWriters": false,
    "package": { "name": "@mento-protocol/issues", "version": "0.2.0" }
  }
}

That lease renews on the hour and holds for at most six hours from the acquire — see "Holding an issue claim for hours" — and verifySubjectKind is on because an issue sweep is exactly the caller that can be handed a pull-request number.

Reference retention

This package deletes nothing. No code path deletes a reference, force-updates one, or passes a zero afterOid. A losing or ambiguous attempt leaves its commit in place as an audit artifact.

That is a deliberate trade with a cost. Custom references are advertised in every clone and fetch — measured at 101 on the monitoring repository — and item numbers never repeat, so the namespace only grows. Two operator procedures cover it, both outside the package:

# how many claim references exist
gh api repos/<owner>/<repo>/git/matching-refs/mento-claims --jq length

# what each one currently says
mento-issues claims list --config <policy> --stale

The matching-refs count covers both namespaces at once, because they share the mento-claims prefix. The listing does not: it reads the namespace of the config it was given, so a repository running both sweeps needs one claims list per configuration document, and the quarterly prune weighs pr and issue separately.

Pruning references for closed and released items is a quarterly operator action, and a policy that forbids the agent from doing it — "forbiddenActions": ["delete-claim-refs"] — is what keeps that boundary honest.

Tests

pnpm --filter @mento-protocol/issues test    # node --test test/*.test.mjs

The suite is fully offline: no network, no gh, no git, and no filesystem writes outside a per-test temporary directory. It runs against createFakeRefServer, which implements the same five-function operations contract production uses and reproduces GitHub's compare-and-swap semantics, plus createFakeClock for deterministic lease arithmetic. Fault injection (failNext, applyThenThrow, partition, heal) exercises the lost-acknowledgement, unknown-outcome and partition paths rather than reasoning about them.

Both fakes ship as @mento-protocol/issues/testing, so a consumer can test its own claim handling the same way.

To regenerate the marker fixture after an intended change:

pnpm --filter @mento-protocol/issues vectors
git diff --exit-code packages/issues/fixtures

Licence

MIT. See LICENSE, which ships in the published tarball.