@biffo/cli 0.236.0 → 0.237.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.
@@ -48,7 +48,7 @@ This file is distributed by the template and kept in step by
48
48
  **Before starting work on an issue, run:**
49
49
 
50
50
  ```bash
51
- sh scripts/claim.sh <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
51
+ sh scripts/biffo.sh claim <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
52
52
  ```
53
53
 
54
54
  Several agent sessions run against this estate at once. The script asks four
@@ -127,7 +127,25 @@ it deliberately and say so in a comment; never steal a fresh one.
127
127
  before merge: `git log origin/<branch> -1`. A green PR page is not proof your
128
128
  latest local commit reached it.
129
129
 
130
- ## 7. Creating this repo correctly (read once, at birth)
130
+ ## 7. Verify post-merge
131
+
132
+ - A merge is not done when the PR closes. **Check the whole branch:**
133
+
134
+ ```bash
135
+ sh scripts/biffo.sh branch-health [-R owner/repo] # 0 green · 1 red · 2 cannot tell
136
+ ```
137
+
138
+ It reports the latest run of **every** workflow on the integration branch, so
139
+ the deploy cannot fall off the bottom of a short `gh run list` — and when
140
+ something is red it names the **first** failing commit, not the newest.
141
+
142
+ - **Check whether the branch was already failing before diagnosing your own
143
+ change.** A red deploy has no audience: the author who broke it has moved on,
144
+ and every later merge fails on damage it did not cause. On 2026-08-02 that
145
+ cost 2h25m and four people each diagnosed their own innocent change.
146
+ - Exit 2 is "cannot tell" and is never a pass.
147
+
148
+ ## 8. Creating this repo correctly (read once, at birth)
131
149
 
132
150
  **No `biffo` command creates a standalone plugin repo.** `biffo plugin create`
133
151
  scaffolds a plugin _into an existing checkout_; a repo like this one is made by
@@ -159,7 +177,7 @@ biffo check branch-protection --repo <org>/<repo>
159
177
  > that means merging with checks still running. Steps 1 and 2 together are what
160
178
  > make the documented flow behave as documented; either alone is not enough.
161
179
 
162
- ## 8. CI runners — two steps this repo cannot do for itself
180
+ ## 9. CI runners — two steps this repo cannot do for itself
163
181
 
164
182
  The workflows use `runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}`, so they
165
183
  work anywhere by default and route to a self-hosted fleet when one exists. Two
@@ -176,7 +194,7 @@ fleet:
176
194
  If a job is queued and nothing is happening, check the grant before anything
177
195
  else. It is the failure that looks exactly like patience.
178
196
 
179
- ## 9. Security
197
+ ## 10. Security
180
198
 
181
199
  - **Never commit secrets** (keys, tokens, credentials, `.env` values).
182
200
  - **Never silently disable a security gate.** If one must be loosened, do it in
@@ -48,7 +48,7 @@ This file is distributed by the template and kept in step by
48
48
  **Before starting work on an issue, run:**
49
49
 
50
50
  ```bash
51
- sh scripts/claim.sh <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
51
+ sh scripts/biffo.sh claim <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
52
52
  ```
53
53
 
54
54
  Several agent sessions run against this estate at once. The script asks four
@@ -127,7 +127,25 @@ it deliberately and say so in a comment; never steal a fresh one.
127
127
  before merge: `git log origin/<branch> -1`. A green PR page is not proof your
128
128
  latest local commit reached it.
129
129
 
130
- ## 7. Security
130
+ ## 7. Verify post-merge
131
+
132
+ - A merge is not done when the PR closes. **Check the whole branch:**
133
+
134
+ ```bash
135
+ sh scripts/biffo.sh branch-health [-R owner/repo] # 0 green · 1 red · 2 cannot tell
136
+ ```
137
+
138
+ It reports the latest run of **every** workflow on the integration branch, so
139
+ the deploy cannot fall off the bottom of a short `gh run list` — and when
140
+ something is red it names the **first** failing commit, not the newest.
141
+
142
+ - **Check whether the branch was already failing before diagnosing your own
143
+ change.** A red deploy has no audience: the author who broke it has moved on,
144
+ and every later merge fails on damage it did not cause. On 2026-08-02 that
145
+ cost 2h25m and four people each diagnosed their own innocent change.
146
+ - Exit 2 is "cannot tell" and is never a pass.
147
+
148
+ ## 8. Security
131
149
 
132
150
  - **Never commit secrets** (keys, tokens, credentials, `.env` values).
133
151
  - **Never silently disable a security gate.** If one must be loosened, do it in
package/dist/index.js CHANGED
@@ -10770,7 +10770,7 @@ function resolveGithubToken4() {
10770
10770
  );
10771
10771
  }
10772
10772
 
10773
- // src/commands/wait-for-checks.ts
10773
+ // src/lib/packaged-script-command.ts
10774
10774
  import { spawnSync } from "child_process";
10775
10775
  import { dirname as dirname11 } from "path";
10776
10776
  import { fileURLToPath as fileURLToPath6 } from "url";
@@ -10794,22 +10794,45 @@ function packagedScriptMissing(relativePath) {
10794
10794
  It ships with this package via cli/scripts/packaged-root-assets.mjs; if you are running from a checkout, run from inside the template repo. If you are running the published package, this is a packaging bug \u2014 the asset was not copied at prepack.`;
10795
10795
  }
10796
10796
 
10797
- // src/commands/wait-for-checks.ts
10798
- var SCRIPT = "scripts/wait-for-checks.sh";
10799
- var waitForChecksCommand = new Command26("wait-for-checks").description(
10800
- "Wait for a PR\u2019s required checks on a positive signal (0 green, 1 failed, 2 cannot tell)"
10801
- ).argument("<pr>", "Pull request number").allowExcessArguments(true).allowUnknownOption(true).action(() => {
10802
- const here = dirname11(fileURLToPath6(import.meta.url));
10803
- const script = findPackagedScript(here, SCRIPT);
10804
- if (!script) {
10805
- process.stderr.write(`${packagedScriptMissing(SCRIPT)}
10797
+ // src/lib/packaged-script-command.ts
10798
+ function packagedScriptCommand(spec) {
10799
+ const command = new Command26(spec.name).description(spec.description).allowExcessArguments(true).allowUnknownOption(true);
10800
+ if (spec.argument) command.argument(`<${spec.argument.name}>`, spec.argument.description);
10801
+ return command.action(() => {
10802
+ const here = dirname11(fileURLToPath6(import.meta.url));
10803
+ const script = findPackagedScript(here, spec.script);
10804
+ if (!script) {
10805
+ process.stderr.write(`${packagedScriptMissing(spec.script)}
10806
10806
  `);
10807
- process.exit(2);
10808
- }
10809
- const at = process.argv.indexOf("wait-for-checks");
10810
- const args = at === -1 ? [] : process.argv.slice(at + 1);
10811
- const result = spawnSync("sh", [script, ...args], { stdio: "inherit" });
10812
- process.exit(result.status === null ? 2 : result.status);
10807
+ process.exit(2);
10808
+ }
10809
+ const at = process.argv.indexOf(spec.name);
10810
+ const args = at === -1 ? [] : process.argv.slice(at + 1);
10811
+ const result = spawnSync("sh", [script, ...args], { stdio: "inherit" });
10812
+ process.exit(result.status === null ? 2 : result.status);
10813
+ });
10814
+ }
10815
+
10816
+ // src/commands/branch-health.ts
10817
+ var branchHealthCommand = packagedScriptCommand({
10818
+ name: "branch-health",
10819
+ script: "scripts/branch-health.sh",
10820
+ description: "Report every workflow on the integration branch (0 green, 1 red, 2 cannot tell)"
10821
+ });
10822
+
10823
+ // src/commands/claim.ts
10824
+ var claimCommand = packagedScriptCommand({
10825
+ name: "claim",
10826
+ script: "scripts/claim.sh",
10827
+ description: "Check and claim an issue before starting (0 free, 1 taken, 2 cannot tell)"
10828
+ });
10829
+
10830
+ // src/commands/wait-for-checks.ts
10831
+ var waitForChecksCommand = packagedScriptCommand({
10832
+ name: "wait-for-checks",
10833
+ script: "scripts/wait-for-checks.sh",
10834
+ description: "Wait for a PR\u2019s required checks on a positive signal (0 green, 1 failed, 2 cannot tell)",
10835
+ argument: { name: "pr", description: "Pull request number" }
10813
10836
  });
10814
10837
 
10815
10838
  // src/index.ts
@@ -10827,6 +10850,8 @@ program.addCommand(deployCommand);
10827
10850
  program.addCommand(destroyCommand);
10828
10851
  program.addCommand(teardownCommand);
10829
10852
  program.addCommand(waitForChecksCommand);
10853
+ program.addCommand(branchHealthCommand);
10854
+ program.addCommand(claimCommand);
10830
10855
  program.addCommand(pluginCommand);
10831
10856
  program.addCommand(dataCommand);
10832
10857
  program.addCommand(coreCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.236.0",
3
+ "version": "0.237.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,9 @@
24
24
  "dist",
25
25
  "schemas",
26
26
  "_skeletons",
27
- "scripts/wait-for-checks.sh"
27
+ "scripts/wait-for-checks.sh",
28
+ "scripts/branch-health.sh",
29
+ "scripts/claim.sh"
28
30
  ],
29
31
  "scripts": {
30
32
  "build": "tsup src/index.ts --format esm --dts --clean",
@@ -1,306 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # Is the integration branch actually healthy — including the deploy, and
4
- # including who broke it?
5
- #
6
- # ## Why this exists
7
- #
8
- # On 2026-08-02 an instance's `dev` deploy went red at 10:43 and nobody noticed
9
- # until 12:36. **1h53m, four further merges**, each of which also failed, and
10
- # development was effectively blocked for 2h25m. Three separate things had to be
11
- # true at once for that to happen, and this script answers all three (#1133).
12
- #
13
- # **1. The obvious command hides the deploy.** An instance runs five workflows on
14
- # a merge to `dev` — `CI`, `CodeQL`, `Core Version Tag`, `Deploy Application`,
15
- # `RLS Tests` — and the reflexive check is:
16
- #
17
- # gh run list --branch dev --limit 3
18
- #
19
- # which returns three that are *not* the deploy. "dev CI green" was reported
20
- # truthfully and repeatedly by an agent following AGENTS.md, while the deploy was
21
- # red the whole time. A truncated list is not a status; it is a sample. This
22
- # script enumerates **every** workflow that ran on the branch and reports the
23
- # latest conclusion of each, so nothing can fall off the bottom.
24
- #
25
- # **2. A red post-merge deploy has no audience.** A failing PR check is noticed
26
- # because someone is watching their PR. A failing *post-merge* deploy is watched
27
- # by nobody: the author has moved on, and the next person only discovers it by
28
- # merging into it. So this notifies, reusing the desktop-alert channel
29
- # `practices-daily.sh` established (`_notify`) — same opt-OUT posture, because an
30
- # opt-in alert is how that notification once spent months existing and never
31
- # firing.
32
- #
33
- # **3. A poisoned branch blames the wrong person.** Once `dev` is broken every
34
- # subsequent merge fails too, so four people each saw *their* change fail. The
35
- # expensive part was not the breakage, it was four independent diagnoses of an
36
- # innocent change. On failure this walks the run history back to the **first
37
- # failing run** and names its commit, author and time — so the fifth person in
38
- # starts at the real cause instead of their own diff.
39
- #
40
- # ## Exit codes
41
- #
42
- # 0 every workflow observed on the branch concluded, none failed
43
- # 1 something failed — the workflow names and the first bad commit are printed
44
- # 2 cannot determine: no runs found, or the repo/branch is unreadable
45
- #
46
- # 2 is deliberately not 0, matching `wait-for-checks.sh` and `ci-wiring-audit.sh`.
47
- # A check that cannot see its input must say so rather than passing: this
48
- # estate's most repeated defect is a zero that means "could not look", and
49
- # `protection-audit.sh` was reporting `27 branches, all protected` while silently
50
- # dropping the four repos least likely to be protected (#1145).
51
- #
52
- # `cancelled` is reported but does not fail the branch. On self-hosted runners it
53
- # is usually spot reclamation or a `cancel-in-progress` concurrency group — two
54
- # merges landing seconds apart cancel the first run by design. It is called out
55
- # by name so nobody debugs a phantom.
56
- #
57
- # ## Usage
58
- #
59
- # sh scripts/branch-health.sh [-R owner/repo] [--branch dev] [--quiet]
60
- #
61
- # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
62
-
63
- set -uo pipefail
64
-
65
- REPO=""
66
- BRANCH=""
67
- QUIET=""
68
-
69
- usage() {
70
- sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
71
- exit 2
72
- }
73
-
74
- while [ $# -gt 0 ]; do
75
- case "$1" in
76
- -R | --repo)
77
- REPO="${2:-}"
78
- shift 2
79
- ;;
80
- --branch)
81
- BRANCH="${2:-}"
82
- shift 2
83
- ;;
84
- --quiet)
85
- QUIET=1
86
- shift
87
- ;;
88
- -h | --help) usage ;;
89
- *)
90
- echo "branch-health: unexpected argument '$1'" >&2
91
- usage
92
- ;;
93
- esac
94
- done
95
-
96
- RED=$(printf '\033[31m')
97
- GREEN=$(printf '\033[32m')
98
- YELLOW=$(printf '\033[33m')
99
- DIM=$(printf '\033[90m')
100
- OFF=$(printf '\033[0m')
101
-
102
- # A real tab, built the same way the colours above are, and used as `IFS="$TAB"`.
103
- #
104
- # AGENTS.md invokes these scripts as `sh scripts/...`, and /bin/sh is dash here.
105
- # The bash spelling `IFS=$'\t'` is NOT a syntax error under dash — it is read as
106
- # the four literal characters `$ ' \ t`, so the field split then happens on any
107
- # of them. The first draft of this script did exactly that and reported a
108
- # workflow called "Deploy Applica", having split "Deploy Application" at its
109
- # 't'. It printed one row instead of four and still exited 0.
110
- #
111
- # Same reason the colours use `$(printf '\033[31m')` rather than `$'\e[31m'`.
112
- TAB=$(printf '\t')
113
-
114
- gh_run() {
115
- if [ -n "$REPO" ]; then gh run "$@" --repo "$REPO"; else gh run "$@"; fi
116
- }
117
-
118
- # The integration branch is `dev` in every Biffo repo (AGENTS.md §2). Resolved
119
- # from the repo rather than hardcoded so this still works in the rare repo whose
120
- # default has not been migrated — and so the failure is "cannot read repo"
121
- # rather than a confident answer about a branch that does not exist.
122
- if [ -z "$BRANCH" ]; then
123
- if [ -n "$REPO" ]; then
124
- BRANCH=$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
125
- else
126
- BRANCH=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
127
- fi
128
- fi
129
-
130
- if [ -z "$BRANCH" ]; then
131
- echo "${RED}branch-health: cannot determine the integration branch.${OFF}" >&2
132
- exit 2
133
- fi
134
-
135
- label=${REPO:-$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")}
136
-
137
- # --- Every workflow that ran on the branch, not the first three --------------
138
- #
139
- # 200 runs is deep enough to reach every workflow's latest run even when a busy
140
- # one (CI) dominates the head of the list. `--json` on `gh run list` returns them
141
- # newest-first, so the first row per workflow name IS its latest run.
142
-
143
- # `group_by | max_by(.createdAt)` rather than "first occurrence in a newest-first
144
- # list": the ordering is gh's to change, and a status tool that quietly reports
145
- # an older run because an API changed its sort is the same class of defect as the
146
- # truncated list this replaces. Ask for the newest explicitly.
147
- summary=$(gh_run list --branch "$BRANCH" --limit 200 \
148
- --json workflowName,status,conclusion,headSha,createdAt,url \
149
- --jq 'group_by(.workflowName)
150
- | map(max_by(.createdAt))
151
- | .[]
152
- | [ (if .status == "completed" then (.conclusion // "unknown") else .status end),
153
- .workflowName, .headSha[0:8], .createdAt[0:16], .url ]
154
- | @tsv' 2>/dev/null)
155
-
156
- if [ -z "$summary" ]; then
157
- echo "${RED}branch-health: no workflow runs readable on '$BRANCH' in $label.${OFF}" >&2
158
- echo "${DIM} That is 'cannot tell', not 'healthy' — exiting 2.${OFF}" >&2
159
- exit 2
160
- fi
161
-
162
- failed=""
163
- pending=""
164
- cancelled=""
165
- skipped=""
166
- ok=""
167
-
168
- while IFS="$TAB" read -r state name sha when url; do
169
- [ -n "$name" ] || continue
170
- case "$state" in
171
- success) ok="${ok}${name}\n" ;;
172
- failure | timed_out | startup_failure)
173
- failed="${failed}${state}\t${name}\t${sha}\t${when}\t${url}\n"
174
- ;;
175
- cancelled) cancelled="${cancelled}${name}\n" ;;
176
- skipped) skipped="${skipped}${name}\n" ;;
177
- *) pending="${pending}${state}\t${name}\n" ;;
178
- esac
179
- done <<EOF
180
- $summary
181
- EOF
182
-
183
- # --- Report -------------------------------------------------------------------
184
-
185
- echo "${DIM}$label — branch '$BRANCH', latest run per workflow${OFF}"
186
- echo
187
-
188
- # `printf '%b'` FIRST, then sed. These lists are accumulated as strings holding
189
- # literal `\n` two-character sequences (POSIX sh has no clean way to append a
190
- # real newline to a variable), so piping them straight to sed hands it a single
191
- # line and only the first entry gets its prefix — which read as a workflow with
192
- # no status at all. Expand the escapes, then prefix each real line.
193
- [ -n "$ok" ] && printf '%b' "$ok" | sed "s/^/ ${GREEN}ok${OFF} /"
194
- [ -n "$skipped" ] && printf '%b' "$skipped" | sed "s/^/ ${DIM}skipped${OFF} /"
195
- [ -n "$cancelled" ] && printf '%b' "$cancelled" | sed "s/^/ ${YELLOW}cancelled${OFF} /"
196
-
197
- if [ -n "$pending" ]; then
198
- printf '%b' "$pending" | awk -F'\t' -v d="$YELLOW" -v o="$OFF" 'NF{printf " %srunning%s %s (%s)\n", d, o, $2, $1}'
199
- fi
200
-
201
- if [ -z "$failed" ]; then
202
- if [ -n "$cancelled" ]; then
203
- echo
204
- echo "${DIM}A cancelled run is usually spot reclamation or a superseded concurrency${OFF}"
205
- echo "${DIM}group, not the code. Re-run it rather than debugging it.${OFF}"
206
- fi
207
- echo
208
- echo "${GREEN}Nothing on '$BRANCH' is failing.${OFF}"
209
- exit 0
210
- fi
211
-
212
- echo
213
- printf '%b' "$failed" | awk -F'\t' -v r="$RED" -v o="$OFF" 'NF{printf " %s%s%s %s at %s %s\n", r, $1, o, $2, $3, $5}'
214
-
215
- # --- Who actually broke it ----------------------------------------------------
216
- #
217
- # The whole point of #1133's third defect. Walk this workflow's runs on this
218
- # branch backwards from the newest failure through consecutive failures, and
219
- # report the OLDEST one in that unbroken streak. That run's commit is where the
220
- # breakage started, which is very often not the person now reading this.
221
-
222
- echo
223
- printf '%b' "$failed" | while IFS="$TAB" read -r state name sha when url; do
224
- [ -n "$name" ] || continue
225
-
226
- # Sort newest-first ourselves, cut the list at the most recent SUCCESS, and
227
- # take the oldest failure still inside that streak. Anything before a green run
228
- # is a different, already-fixed breakage and must not be blamed for this one.
229
- first=$(gh_run list --branch "$BRANCH" --workflow "$name" --limit 60 \
230
- --json conclusion,headSha,createdAt,displayTitle,url \
231
- --jq 'sort_by(.createdAt) | reverse
232
- | (map(.conclusion == "success") | index(true)) as $green
233
- | .[0: (if $green == null then length else $green end)]
234
- | map(select(.conclusion == "failure"
235
- or .conclusion == "timed_out"
236
- or .conclusion == "startup_failure"))
237
- | last
238
- | select(. != null)
239
- | [ .headSha[0:8], .createdAt[0:16], (.displayTitle // "")[0:72], .url ]
240
- | @tsv' 2>/dev/null)
241
-
242
- if [ -n "$first" ]; then
243
- f_sha=$(printf '%s' "$first" | cut -f1)
244
- f_when=$(printf '%s' "$first" | cut -f2)
245
- f_title=$(printf '%s' "$first" | cut -f3)
246
- f_url=$(printf '%s' "$first" | cut -f4)
247
- echo " ${RED}$name${OFF} has been failing since ${YELLOW}$f_sha${OFF} ($f_when)"
248
- echo " $f_title"
249
- echo " ${DIM}$f_url${OFF}"
250
- if [ "$f_sha" != "$sha" ]; then
251
- echo " ${DIM}The newest failure is at $sha — but it is NOT where this started.${OFF}"
252
- echo " ${DIM}Diagnose $f_sha, not your own merge.${OFF}"
253
- fi
254
- else
255
- echo " ${RED}$name${OFF} is failing at $sha ${DIM}(could not establish when it started)${OFF}"
256
- fi
257
- done
258
-
259
- # --- Tell somebody ------------------------------------------------------------
260
- #
261
- # Copied in posture, deliberately, from practices-daily.sh's `_notify`: opt-OUT
262
- # via an env var rather than opt-in, because an opt-in alert is one that never
263
- # fires. Replaces its own previous card rather than stacking, so a branch red for
264
- # three days is one notification and not three.
265
-
266
- _notify() {
267
- [ -z "$QUIET" ] || return 0
268
- command -v notify-send >/dev/null 2>&1 || return 0
269
- [ -z "${BRANCH_HEALTH_NO_DESKTOP_ALERT:-}" ] || return 0
270
-
271
- if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
272
- _bus="/run/user/$(id -u)/bus"
273
- [ -S "$_bus" ] || return 0
274
- DBUS_SESSION_BUS_ADDRESS="unix:path=$_bus"
275
- export DBUS_SESSION_BUS_ADDRESS
276
- fi
277
-
278
- _slug=$(printf '%s' "$label-$BRANCH" | tr -c 'a-zA-Z0-9' '-')
279
- _idfile="${XDG_RUNTIME_DIR:-/tmp}/biffo-branch-health-${_slug}.id"
280
- _prev=""
281
- [ -f "$_idfile" ] && _prev=$(cat "$_idfile" 2>/dev/null)
282
-
283
- # Built as a plain variable rather than `${_prev:+--replace-id="$_prev"}`.
284
- # That form nests double quotes inside a parameter expansion, which dash
285
- # refuses to parse — and because the script is run as `sh`, the failure lands
286
- # at RUNTIME, after all the useful output has already printed, turning a
287
- # correct exit 1 into a confusing exit 2.
288
- _replace=""
289
- [ -n "$_prev" ] && _replace="--replace-id=$_prev"
290
-
291
- _names=$(printf '%b' "$failed" | awk -F'\t' 'NF{printf "%s ", $2}')
292
- # Deliberately unquoted: empty must expand to no argument at all.
293
- # shellcheck disable=SC2086
294
- _new=$(notify-send --print-id $_replace \
295
- -u critical -a "biffo" \
296
- "$label: $BRANCH is red" \
297
- "$_names— nobody is watching a post-merge failure. sh scripts/branch-health.sh" 2>/dev/null)
298
- [ -n "$_new" ] && printf '%s' "$_new" > "$_idfile" 2>/dev/null
299
- return 0
300
- }
301
-
302
- _notify
303
-
304
- echo
305
- echo "${RED}'$BRANCH' is red. It blocks everyone — fixing it is the next task (AGENTS.md §6).${OFF}"
306
- exit 1
@@ -1,208 +0,0 @@
1
- #!/usr/bin/env sh
2
- #
3
- # Is anyone already working this issue? Ask git, not a label.
4
- #
5
- # ## Why this exists
6
- #
7
- # Several agent sessions run against this estate at once, and on 2026-08-03
8
- # **four** of them collided in one morning:
9
- #
10
- # - #1165 — another session built AND MERGED a PR for it while this one was
11
- # claiming it. Three minutes, start to merge.
12
- # - #1174 — a live worktree on `fix/1174-…` existed; the issue was unlabelled.
13
- # - #621, #956 — live worktrees, no labels. Labelled on their behalf.
14
- # - #1188 — the reverse: a label with no work, while another session built it
15
- # and opened a PR.
16
- #
17
- # Three of the four were "work exists, label does not". That is the shape this
18
- # script is for.
19
- #
20
- # ## The rule it encodes
21
- #
22
- # **The `in-progress` label is a hand-maintained second copy of something git
23
- # already knows.** A branch exists. A PR exists. Those are automatic — you
24
- # cannot do the work without creating them — whereas the label is a separate
25
- # action a human or agent has to remember, in a workflow that may never have
26
- # been told to. Second copies of a decision drift; this estate says so about
27
- # `_extract_detail`, about AGENTS.md, and about the commit-msg type list.
28
- #
29
- # So this checks FOUR signals and reports all of them, rather than trusting the
30
- # one that is easiest to forget.
31
- #
32
- # ## What it cannot do
33
- #
34
- # Prevent a race. GitHub has no locking, two sessions can start in the same
35
- # second, and #1165 went from branch to merged in three minutes — no protocol
36
- # would have caught that. The goal is early, cheap detection, not exclusion.
37
- # Every collision that morning was caught before duplicate work merged; the
38
- # cost was minutes, not shipped rework.
39
- #
40
- # ## Usage
41
- #
42
- # sh scripts/claim.sh 1234 # check, and claim if free
43
- # sh scripts/claim.sh 1234 --check # report only, change nothing
44
- # sh scripts/claim.sh 1234 -R owner/repo
45
- #
46
- # 0 free — and claimed, unless --check
47
- # 1 taken, or already closed — the reason is printed
48
- # 2 cannot tell — issue unreadable, gh unauthenticated
49
- #
50
- # 2 is deliberately not 0, matching `wait-for-checks.sh` and `branch-health.sh`.
51
- # A check that cannot see its input must not report "free".
52
- #
53
- # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary needed.
54
-
55
- set -u
56
-
57
- ISSUE=""
58
- REPO=""
59
- CHECK_ONLY=""
60
-
61
- usage() {
62
- sed -n '2,58p' "$0" | sed 's/^# \{0,1\}//'
63
- exit 2
64
- }
65
-
66
- while [ $# -gt 0 ]; do
67
- case "$1" in
68
- -R | --repo)
69
- REPO="${2:-}"
70
- shift 2
71
- ;;
72
- --check) CHECK_ONLY=1; shift ;;
73
- -h | --help) usage ;;
74
- *)
75
- ISSUE="$1"
76
- shift
77
- ;;
78
- esac
79
- done
80
-
81
- case "$ISSUE" in
82
- '' | *[!0-9]*)
83
- echo "claim: give an issue number, e.g. sh scripts/claim.sh 1234" >&2
84
- exit 2
85
- ;;
86
- esac
87
-
88
- RED=$(printf '\033[31m')
89
- GREEN=$(printf '\033[32m')
90
- YELLOW=$(printf '\033[33m')
91
- DIM=$(printf '\033[90m')
92
- OFF=$(printf '\033[0m')
93
-
94
- gh_issue() { if [ -n "$REPO" ]; then gh issue "$@" --repo "$REPO"; else gh issue "$@"; fi; }
95
- gh_pr() { if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi; }
96
-
97
- LABEL=in-progress
98
- TAKEN=0
99
- REASONS=""
100
-
101
- note() { REASONS="${REASONS} $1\n"; TAKEN=1; }
102
-
103
- # --- 0. Does the issue exist, and is it still open? --------------------------
104
-
105
- meta=$(gh_issue view "$ISSUE" --json state,title,labels \
106
- --jq '"\(.state)\t\(.title)\t\((.labels|map(.name)|join(",")))"' 2>/dev/null) || {
107
- echo "${RED}claim: cannot read issue #$ISSUE${OFF} — wrong repo, or gh not authenticated." >&2
108
- echo "${DIM} That is 'cannot tell', not 'free'.${OFF}" >&2
109
- exit 2
110
- }
111
-
112
- state=$(printf '%s' "$meta" | cut -f1)
113
- title=$(printf '%s' "$meta" | cut -f2)
114
- labels=$(printf '%s' "$meta" | cut -f3)
115
-
116
- echo "${DIM}#$ISSUE — $title${OFF}"
117
- echo
118
-
119
- if [ "$state" != "OPEN" ]; then
120
- echo "${RED}Already $state.${OFF} Nothing to claim."
121
- exit 1
122
- fi
123
-
124
- # --- 1. The label. Easiest to check, easiest to forget. ----------------------
125
-
126
- case ",$labels," in
127
- *",$LABEL,"*)
128
- updated=$(gh_issue view "$ISSUE" --json updatedAt --jq .updatedAt 2>/dev/null)
129
- note "${YELLOW}label${OFF} carries '$LABEL' (issue last updated $updated)"
130
- ;;
131
- esac
132
-
133
- # --- 2. An open PR that references it ----------------------------------------
134
- #
135
- # The strongest signal, because a PR cannot be opened without the work existing.
136
- # Matches the issue number in the title or body as a whole number, so #118 does
137
- # not match #1188.
138
-
139
- open_prs=$(gh_pr list --state open --limit 100 --json number,title,body,headRefName \
140
- --jq "[.[] | select(((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\")) or (.headRefName | test(\"(^|[^0-9])$ISSUE([^0-9]|\$)\")))] | .[] | \"#\(.number) \(.headRefName)\"" 2>/dev/null)
141
-
142
- if [ -n "$open_prs" ]; then
143
- printf '%s\n' "$open_prs" | while IFS= read -r pr; do
144
- [ -n "$pr" ] && echo " ${RED}open PR${OFF} $pr"
145
- done
146
- note "${RED}open PR${OFF} see above — someone has working code"
147
- fi
148
-
149
- # --- 3. A remote branch naming it --------------------------------------------
150
- #
151
- # Catches work that has been pushed but has no PR yet. Whole-number match again.
152
-
153
- branches=$(git ls-remote --heads "${REPO:+https://github.com/$REPO.git}" 2>/dev/null |
154
- sed 's|.*refs/heads/||' |
155
- grep -E "(^|[^0-9])$ISSUE([^0-9]|$)" 2>/dev/null)
156
-
157
- if [ -n "$branches" ]; then
158
- printf '%s\n' "$branches" | while IFS= read -r b; do
159
- [ -n "$b" ] && echo " ${RED}branch${OFF} $b"
160
- done
161
- note "${RED}branch${OFF} a remote branch names this issue"
162
- fi
163
-
164
- # --- 4. A recently merged PR that already closed it --------------------------
165
- #
166
- # Not "taken" — "possibly already done". #1165 was built and merged in three
167
- # minutes; the only trace afterwards is a merged PR.
168
-
169
- merged=$(gh_pr list --state merged --limit 30 --json number,title,body,mergedAt \
170
- --jq "[.[] | select((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\"))] | .[0] | select(. != null) | \"#\(.number) merged \(.mergedAt[0:16])\"" 2>/dev/null)
171
-
172
- if [ -n "$merged" ]; then
173
- echo " ${YELLOW}merged${OFF} $merged"
174
- echo " ${DIM} the issue is still open, but work referencing it has landed —${OFF}"
175
- echo " ${DIM} read it before rebuilding.${OFF}"
176
- fi
177
-
178
- # --- Verdict ------------------------------------------------------------------
179
-
180
- echo
181
- if [ "$TAKEN" -eq 1 ]; then
182
- printf '%b' "${RED}Taken.${OFF} Signals:\n$REASONS"
183
- echo
184
- echo "${DIM}If you believe it is abandoned, check how old the work is and say so in a${OFF}"
185
- echo "${DIM}comment before taking it. Never steal a fresh claim.${OFF}"
186
- exit 1
187
- fi
188
-
189
- if [ -n "$CHECK_ONLY" ]; then
190
- echo "${GREEN}Free.${OFF} ${DIM}(--check: nothing changed)${OFF}"
191
- exit 0
192
- fi
193
-
194
- # Claim it. Label AND comment together: the label is what other sessions filter
195
- # on, the comment is what dates it so a stale claim can be recognised later.
196
- gh_issue edit "$ISSUE" --add-label "$LABEL" >/dev/null 2>&1 || {
197
- echo "${RED}claim: could not apply the '$LABEL' label.${OFF}" >&2
198
- echo "${DIM} Not claimed. Do not start work on the assumption that it worked.${OFF}" >&2
199
- exit 2
200
- }
201
- gh_issue comment "$ISSUE" \
202
- --body "Claimed at $(date -u +%FT%TZ) by \`$(git config user.name 2>/dev/null || echo agent)\`. Release it — remove the label — on merge, or if you stop." \
203
- >/dev/null 2>&1
204
-
205
- echo "${GREEN}Claimed.${OFF}"
206
- echo "${DIM}Push your branch as soon as it exists: a claim is a reservation, the branch${OFF}"
207
- echo "${DIM}is the evidence, and the window between them is where collisions happen.${OFF}"
208
- exit 0