@plot-pm/board 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-host.sh CHANGED
@@ -8,6 +8,17 @@
8
8
  #
9
9
  # Ops (the ~6 operations plot actually needs):
10
10
  # backend print the resolved backend: github|bitbucket
11
+ # REPORTS WHAT THE REPO DECLARED. An
12
+ # unrecognised `Git host` word is NOT silently
13
+ # defaulted to github — that drove a repo
14
+ # declaring `gitlab` through `gh` until
15
+ # 2026-09-08. A backend this script has no arm
16
+ # for exits 4 and names the word on stderr:
17
+ # exit 4 is "this backend has no answer at
18
+ # all", a configuration a person fixes rather
19
+ # than a transient to retry. Adding a host is
20
+ # an edit to `HOST_DRIVES` and the arms below,
21
+ # and to nothing in `packages/domain`.
11
22
  # default-branch print the repo's default branch name
12
23
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
13
24
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
@@ -41,6 +52,12 @@
41
52
  # pr-ready <number> take a PR out of draft
42
53
  # merge the PR
43
54
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
55
+ # [--repo <owner/repo>] pins the list to ONE
56
+ # repository, exactly as pr-state and pr-merged
57
+ # do. A checkout with remotes on two hosts lets
58
+ # an unpinned list resolve the wrong one, and a
59
+ # caller joining it against `origin/*` refs then
60
+ # reads every branch as having no PR.
44
61
  # JSON lines: {"number":N,"title":"...",
45
62
  # "state":"...","head":"..."}
46
63
  # --rich adds: draft, checks, mergeable, review,
@@ -122,10 +139,12 @@
122
139
  # issue-list [--limit N] open tracker issues as JSON lines:
123
140
  # {"number":N,"title":"…","url":"…",
124
141
  # "createdAt":"…"}
125
- # READ-ONLY, and the only issue op here: Plot
126
- # never writes to the tracker (no labels, no
127
- # assignees, no close-on-merge), because a copy
128
- # of tracker state ages into a lie.
142
+ # READ-ONLY. Plot writes no label, no assignee
143
+ # and no close-on-merge, because a copy of
144
+ # tracker state ages into a lie. The one write
145
+ # that exists is `issue-status`, and it records
146
+ # a status Plot itself just caused rather than
147
+ # mirroring one the tracker holds.
129
148
  # `url` is "" when the host omits it, and a
130
149
  # consumer renders the number as plain text
131
150
  # rather than inventing an address — the rule
@@ -157,8 +176,8 @@
157
176
  # tickets*, the failure this story is named for.
158
177
  # issue-view <number> ONE open issue as a single JSON object:
159
178
  # {"number":N,"title":"…","body":"…","url":"…"}
160
- # STILL READ-ONLY — the second issue op, and it
161
- # reads. The board's *Create plan* action needs
179
+ # READ-ONLY — the second of the two issue reads.
180
+ # The board's *Create plan* action needs
162
181
  # the issue's BODY as the problem statement, and
163
182
  # issue-list deliberately omits it: the list is
164
183
  # asked on a timer for every open issue, and a
@@ -183,6 +202,28 @@
183
202
  # as issue-list. Jira answers 404 for a missing
184
203
  # key, which is exit 3 here (the tracker moved),
185
204
  # never an empty body.
205
+ # issue-status <key> <status> THE ONE WRITE TO A TRACKER: record one status
206
+ # against one issue, and nothing else. No
207
+ # create, no close, no comment, no label — a
208
+ # plan referencing an issue is Plot's record,
209
+ # and the status is the single fact the tracker
210
+ # owns a copy of.
211
+ # JIRA ONLY. Exit 4 where `Tracker` is not jira:
212
+ # this adapter cannot be asked, which is neither
213
+ # a failure nor a silent success. The other
214
+ # vendor's projects surface is written by
215
+ # `plot-update-board.sh`, under its own
216
+ # credentials and through a different API — the
217
+ # reason the tracker port has two connectors.
218
+ # Prints `written` where the transition was
219
+ # performed and `no-target` where the workflow
220
+ # offers no such transition from the issue's
221
+ # current state — which is also what a repeated
222
+ # write reports, because the same status twice
223
+ # is the same status.
224
+ # The transition id is LOOKED UP, never guessed:
225
+ # ids are per workflow and per issue, so a
226
+ # hardcoded one writes to the wrong column.
186
227
  # pr-body <number> --body B replace the PR description
187
228
  # rate-limit both GitHub budgets from `gh api rate_limit`.
188
229
  # SUPERSEDED BY `limit`, and kept only because
@@ -336,6 +377,52 @@ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
336
377
  fi
337
378
  }
338
379
 
380
+ # --- the connector names its own repair -------------------------------------
381
+ #
382
+ # THE TEXT COMES FROM THE CONNECTOR THAT FAILED, never from a caller branching
383
+ # on the stack. A refusal saying `gh auth login` is wrong advice on a Bitbucket
384
+ # team, and it is wrong in the direction that costs most: a teammate who has not
385
+ # read this repository runs the command they were given, it does nothing, and
386
+ # the next step is to find a person. This connector already resolved its vendor
387
+ # — `backend` above — so it is the one place that can name a CLI that exists.
388
+ #
389
+ # ONE FUNCTION RATHER THAN A TABLE AT EACH CALL SITE, and it is the connector
390
+ # contract CLAUDE.md already states applied to words instead of budgets: only a
391
+ # connector knows its account, its transport and its login command, so only a
392
+ # connector may name them.
393
+ #
394
+ # A BACKEND THIS DOES NOT KNOW GETS NO ADVICE. Inventing a login command for an
395
+ # unrecognised vendor is the same failure as naming the wrong one, so it says
396
+ # what it does not know rather than guessing — the direction `host_failure_kind`
397
+ # refuses in too.
398
+ #
399
+ # $1 = the backend word, as `backend` reported it.
400
+ host_cli() { # → the CLI this backend drives, or '' where none is known
401
+ case "$1" in
402
+ github) echo "gh" ;;
403
+ bitbucket) echo "bb" ;;
404
+ *) echo "" ;;
405
+ esac
406
+ }
407
+
408
+ # The repair line for a host call that failed, on stderr, in this connector's
409
+ # own words. Prints nothing where the vendor is unknown — see above.
410
+ host_repair() { # $1=backend
411
+ local cli; cli="$(host_cli "$1")"
412
+ if [ -z "$cli" ]; then
413
+ echo " Plot does not know which CLI drives '$1', so it cannot name the fix." >&2
414
+ echo " Set 'Git host' in CLAUDE.md to a host Plot drives (github, bitbucket)." >&2
415
+ return
416
+ fi
417
+ if ! command -v "$cli" >/dev/null 2>&1; then
418
+ echo " '$cli' is not on PATH, and this backend is driven through it." >&2
419
+ echo " Install it, then: $cli auth login" >&2
420
+ return
421
+ fi
422
+ echo " Check the CLI can answer: $cli auth status" >&2
423
+ echo " If it is not logged in: $cli auth login" >&2
424
+ }
425
+
339
426
  # A failed `pr-list`, reported and never swallowed.
340
427
  #
341
428
  # THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
@@ -350,13 +437,28 @@ pr_list_failed() { # $1=stderr text
350
437
  local err="$1"
351
438
  case "$(host_failure_kind "$err")" in
352
439
  secondary)
353
- die6 "pr-list: host refused a burst ${err:-the host refused the request and said nothing}"
440
+ # NO REPAIR NAMED, AND THAT IS THE ANSWER. A burst refusal clears in
441
+ # seconds and nothing is broken, so `$cli auth login` here would send a
442
+ # reader to fix a login that is already fine. The decision is the wait.
443
+ echo "plot-host: pr-list: host refused a burst — ${err:-the host refused the request and said nothing}" >&2
444
+ echo " Nothing is wrong and nothing needs fixing: this limit bounds calls at" >&2
445
+ echo " once, not per hour. Retry shortly, with fewer at a time." >&2
446
+ exit 6
354
447
  ;;
355
448
  throttled)
356
- die5 "pr-list: host throttled — ${err:-the host refused the request and said nothing}"
449
+ echo "plot-host: pr-list: host throttled — ${err:-the host refused the request and said nothing}" >&2
450
+ echo " The window's quota is spent. Wait for the reset the message names," >&2
451
+ echo " or run against an account with quota left. No login will help." >&2
452
+ exit 5
357
453
  ;;
358
454
  esac
359
- die3 "pr-list: ${err:-the host failed the request and said nothing}"
455
+ # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
456
+ # the repair for the first is a login this connector can name — see
457
+ # `host_repair`, which asks the CLI this backend drives rather than assuming
458
+ # one. A failure that is neither costs the reader one `auth status`.
459
+ echo "plot-host: pr-list: ${err:-the host failed the request and said nothing}" >&2
460
+ host_repair "$(backend)"
461
+ exit 3
360
462
  }
361
463
 
362
464
  # Run one `pr-list` host call, or die reporting which failure it was.
@@ -556,7 +658,23 @@ jenkins_build_map() {
556
658
  # `LC_ALL=C` on the match: the CLI localises its messages, and a matcher that
557
659
  # only works in English would silently reclassify every miss as an outage for
558
660
  # anyone else.
661
+ #
662
+ # A MISSING CLI IS NOT A MISS, AND THE BARE `not found` ALTERNATIVE COULD NOT
663
+ # TELL THEM APART. Measured 2026-09-06 with `gh` off `PATH`: the shell says
664
+ # `bash: gh: command not found`, which matched — so `pr-merged` answered
665
+ # `not-merged` where `plot-pr-merged.sh` answered `unaskable` about the same
666
+ # branch. `not-merged` reads to `rules/landed.ts` as `none` (the host spoke and
667
+ # said nothing merged), so `mayRemove` may permit a removal; `unaskable`
668
+ # refuses. `plot-release-refs.sh` deletes remote refs on that answer and a
669
+ # deleted ref is not re-creatable, so the two answers differ in the one
670
+ # direction this estate has said it will not fail in.
671
+ #
672
+ # The bare alternative stays — it is what recognises a Bitbucket or Jira miss,
673
+ # whose wording is neither `no pull requests found` nor `could not find`. What
674
+ # it excludes is the shell's own phrasing for an absent binary, which is a
675
+ # transport failure wearing a miss's words.
559
676
  is_lookup_miss() {
677
+ case "$1" in *'command not found'*) return 1 ;; esac
560
678
  LC_ALL=C grep -qiE 'no (pull request|pullrequest)s? (found|match)|could not find.*pull request|not found' <<<"$1"
561
679
  }
562
680
 
@@ -1179,7 +1297,10 @@ jira_require_config() {
1179
1297
  die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
1180
1298
  fi
1181
1299
  if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
1182
- die3 "Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox"
1300
+ echo "plot-host: Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox" >&2
1301
+ echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
1302
+ echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>." >&2
1303
+ exit 3
1183
1304
  fi
1184
1305
  }
1185
1306
 
@@ -1302,21 +1423,64 @@ pr_list_report_truncation() {
1302
1423
  echo "plot-host: $be pr-list state=$state possibly truncated ($count rows, requested limit $limit unprovable) — a join against this page may read older branches as 'no PR' (#333)" >&2
1303
1424
  }
1304
1425
 
1305
- backend() {
1426
+ # The backends this script has an arm for, which is what "drivable" means here.
1427
+ #
1428
+ # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
1429
+ # host means teaching the ops below its CLI; this array is the record of which
1430
+ # have been taught, and it sits beside the arms it describes. `packages/domain`
1431
+ # holds no copy — `HostBackend` is any string, and `host-shell.ts` passes
1432
+ # through whatever this prints — so a third host is an edit here and nowhere in
1433
+ # the domain.
1434
+ HOST_DRIVES="github bitbucket"
1435
+
1436
+ # Is this a backend the ops below can actually drive?
1437
+ host_drivable() { # $1=backend word
1438
+ case " $HOST_DRIVES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
1439
+ }
1440
+
1441
+ # The backend this repository declared, in the word it used.
1442
+ #
1443
+ # REPORTS WHAT IT WAS TOLD, AND DEFAULTS ONLY WHERE NOTHING WAS SAID. A repo
1444
+ # declaring `Git host: gitlab` was answered `github` until 2026-09-08 — the
1445
+ # unrecognised word was discarded and the default returned in its place, so a
1446
+ # GitLab team was driven through `gh` and every refusal named a host they had
1447
+ # not configured. An absent key still defaults to github, which is the same
1448
+ # answer for the same reason: nothing was said, so nothing was discarded.
1449
+ #
1450
+ # `bb` is an ALIAS rather than an unknown word, and it normalises to bitbucket.
1451
+ backend_declared() {
1306
1452
  if [ -n "${PLOT_HOST:-}" ]; then
1307
- case "$PLOT_HOST" in
1308
- github|bitbucket) echo "$PLOT_HOST"; return ;;
1309
- *) die "unknown PLOT_HOST '$PLOT_HOST' (github|bitbucket)" ;;
1310
- esac
1453
+ printf '%s\n' "$PLOT_HOST" | tr '[:upper:]' '[:lower:]'
1454
+ return
1311
1455
  fi
1312
1456
  local v
1313
1457
  v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
1314
1458
  case "$v" in
1315
- bitbucket|bb) echo "bitbucket" ;;
1316
- *) echo "github" ;;
1459
+ bb) echo "bitbucket" ;;
1460
+ "") echo "github" ;;
1461
+ *) printf '%s\n' "$v" ;;
1317
1462
  esac
1318
1463
  }
1319
1464
 
1465
+ # The resolved backend, refused where this script has no arm for it.
1466
+ #
1467
+ # THE REFUSAL IS THIS LAYER'S, AND IT NAMES THE WORD. Every op below dispatches
1468
+ # on this answer, so a word with no arm must stop here rather than fall into
1469
+ # whichever branch happens to be last — that is how `gitlab` became a Bitbucket
1470
+ # call. Exit 4 says the question cannot be asked of this backend AT ALL, which
1471
+ # is what an unknown host is: not a broken call to retry, but a configuration a
1472
+ # person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
1473
+ # sentence below for the name.
1474
+ backend() {
1475
+ local v
1476
+ v="$(backend_declared)" || return 1
1477
+ if ! host_drivable "$v"; then
1478
+ echo "plot-host: cannot drive '$v' — this script drives ${HOST_DRIVES// /, }; set the 'Git host' key in CLAUDE.md (or \$PLOT_HOST) to one of them" >&2
1479
+ return 4
1480
+ fi
1481
+ printf '%s\n' "$v"
1482
+ }
1483
+
1320
1484
 
1321
1485
  # --- the connector counts what it spends -----------------------------------
1322
1486
  #
@@ -1756,7 +1920,11 @@ jen() {
1756
1920
 
1757
1921
  op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
1758
1922
  shift
1759
- be="$(backend)" || exit 1
1923
+ # THE BACKEND'S REFUSAL IS PASSED THROUGH, NOT FLATTENED. `backend` exits 4 for
1924
+ # a host it has no arm for, and exit 4 is the contract's "this backend has no
1925
+ # answer at all" — the one code every caller reads as permanent rather than
1926
+ # transient. Collapsing it to 1 here would tell a GitLab repo to retry forever.
1927
+ be="$(backend)" || exit $?
1760
1928
 
1761
1929
  # EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
1762
1930
  # the op runs and its answer is read from `$route` by whichever arm needs it —
@@ -2067,6 +2235,13 @@ case "$op" in
2067
2235
  pr-list)
2068
2236
  state="open"
2069
2237
  rich=0
2238
+ # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
2239
+ # `pr-merged` already take. A checkout may carry several remotes on several
2240
+ # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
2241
+ # — so a caller comparing `origin/*` refs would join its refs against
2242
+ # another repository's PRs and report every branch as having none. The
2243
+ # caller knows which remote its refs came from; this op cannot guess it.
2244
+ repo_args=()
2070
2245
  # `gh pr list` and `bb pr list` both cap at 30 by default. That is invisible
2071
2246
  # with --state open (few repos have 30 open PRs) and bites immediately with
2072
2247
  # --state all, where the newest 30 crowd out every older merged PR. A caller
@@ -2078,6 +2253,7 @@ case "$op" in
2078
2253
  --state) state="${2:?}"; shift 2 ;;
2079
2254
  --limit) limit="${2:?}"; shift 2 ;;
2080
2255
  --rich) rich=1; shift ;;
2256
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
2081
2257
  *) die "pr-list: unknown arg $1" ;;
2082
2258
  esac
2083
2259
  done
@@ -2107,7 +2283,11 @@ case "$op" in
2107
2283
  jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
2108
2284
  [ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
2109
2285
  if [ -z "$jen_instance" ]; then
2110
- die3 "CI is jenkins but no Jenkins instance is configured (set a 'Jenkins instance' key)"
2286
+ echo "plot-host: CI is jenkins but no Jenkins instance is configured" >&2
2287
+ echo " Add a 'Jenkins instance' key to the ## Plot Config section of CLAUDE.md," >&2
2288
+ echo " naming the instance \`jen\` knows, or set JENKINS_INSTANCE." >&2
2289
+ echo " Or drop the 'CI: jenkins' key to read build status from the git host." >&2
2290
+ exit 3
2111
2291
  fi
2112
2292
  jen_payload=$(jenkins_build_map "$jen_instance")
2113
2293
  jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
@@ -2196,7 +2376,7 @@ case "$op" in
2196
2376
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
2197
2377
  # $jentry == null → the branch has no Jenkins job; `none`.
2198
2378
  # otherwise → the joined colour's `checks`, job named on fail.
2199
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2379
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2200
2380
  --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
2201
2381
  pr_list_report_truncation github "$limit" "$state" \
2202
2382
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2225,7 +2405,7 @@ case "$op" in
2225
2405
  }'
2226
2406
  else
2227
2407
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
2228
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2408
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2229
2409
  --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
2230
2410
  pr_list_report_truncation github "$limit" "$state" \
2231
2411
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2256,7 +2436,7 @@ case "$op" in
2256
2436
  }'
2257
2437
  fi
2258
2438
  else
2259
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2439
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2260
2440
  --json number,title,state,headRefName)" || exit $?
2261
2441
  pr_list_report_truncation github "$limit" "$state" \
2262
2442
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2296,7 +2476,7 @@ case "$op" in
2296
2476
  # the GitHub arm uses, which is why it lives above the backend branch.
2297
2477
  # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
2298
2478
  for _s in $bb_states; do
2299
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2479
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2300
2480
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2301
2481
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2302
2482
  printf '%s' "$_bb_raw" \
@@ -2325,7 +2505,7 @@ case "$op" in
2325
2505
  else
2326
2506
  # Bitbucket without Jenkins: checks remain unknown
2327
2507
  for _s in $bb_states; do
2328
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2508
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2329
2509
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2330
2510
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2331
2511
  printf '%s' "$_bb_raw" \
@@ -2334,7 +2514,7 @@ case "$op" in
2334
2514
  fi
2335
2515
  else
2336
2516
  for _s in $bb_states; do
2337
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2517
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2338
2518
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2339
2519
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2340
2520
  printf '%s' "$_bb_raw" \
@@ -2690,6 +2870,55 @@ case "$op" in
2690
2870
  fi
2691
2871
  ;;
2692
2872
 
2873
+ issue-status)
2874
+ # THE ONE WRITE TO A TRACKER, and the amendment this op records: Plot writes
2875
+ # a STATUS to the tracker it was told about, and writes nothing else. No
2876
+ # ticket is created, none is closed, no comment, label or assignee is
2877
+ # touched. A plan referencing an issue stays Plot's record; the status is
2878
+ # the one fact the tracker owns a copy of, because it is the one a person
2879
+ # reads in the tracker rather than in Plot.
2880
+ #
2881
+ # JIRA ONLY, and deliberately so rather than by omission. This vendor's
2882
+ # projects surface has a script of its own (`plot-update-board.sh`) reached
2883
+ # by its own connector, and the two write through different APIs under
2884
+ # different credentials — which is why the tracker port has two connectors
2885
+ # rather than one arm with a branch. A repo whose `Tracker` is not jira gets
2886
+ # exit 4 here: this adapter cannot be asked, which is not a failure and not
2887
+ # a silent success.
2888
+ #
2889
+ # IDEMPOTENT BY NATURE. The same status written twice is the same status,
2890
+ # and Jira answers a transition to the state an issue already holds by
2891
+ # naming no such transition — which this reports as `no-target` rather than
2892
+ # as a failure.
2893
+ key="${1:?issue-status needs an issue key}"; shift
2894
+ want="${1:?issue-status needs a status}"; shift
2895
+ [ "$(tracker_scheme)" = "jira" ] || exit 4
2896
+ jira_require_config
2897
+ # THE TRANSITION IS LOOKED UP, NEVER GUESSED. Jira transitions are per
2898
+ # workflow and per issue: the id for "In Progress" differs between projects
2899
+ # and the transition may not be available from the issue's current state at
2900
+ # all. A hardcoded id writes a status to the wrong column silently.
2901
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions")"; curl_rc=$?
2902
+ body_json="$(jira_check "$raw" "$curl_rc")" || exit $?
2903
+ tid="$(printf '%s' "$body_json" | jq -r --arg want "$want" \
2904
+ '[.transitions[]? | select((.name // "" | ascii_downcase) == ($want | ascii_downcase)
2905
+ or ((.to.name // "" | ascii_downcase) == ($want | ascii_downcase)))]
2906
+ | .[0].id // ""')"
2907
+ if [ -z "$tid" ]; then
2908
+ # NO SUCH TRANSITION IS AN ANSWER, not a failure. The tracker was reached
2909
+ # and holds nowhere to put this status — either the workflow has no such
2910
+ # state or the issue is already in it. Reporting it as a failure would
2911
+ # make a repeated write look like an outage.
2912
+ printf '%s\n' 'no-target'
2913
+ exit 0
2914
+ fi
2915
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions" \
2916
+ -X POST -H 'Content-Type: application/json' \
2917
+ --data "$(jq -cn --arg id "$tid" '{transition:{id:$id}}')")"; curl_rc=$?
2918
+ jira_check "$raw" "$curl_rc" >/dev/null || exit $?
2919
+ printf '%s\n' 'written'
2920
+ ;;
2921
+
2693
2922
  pr-body)
2694
2923
  num="${1:?pr-body needs a PR number}"; shift
2695
2924
  body=""
@@ -2955,6 +3184,6 @@ case "$op" in
2955
3184
  ;;
2956
3185
 
2957
3186
  *)
2958
- die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit|limit|ci-limit|spend-rate)"
3187
+ die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|issue-status|pr-body|rate-limit|limit|ci-limit|spend-rate)"
2959
3188
  ;;
2960
3189
  esac
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE answer to "is this monitor's subject still there?"
3
+ #
4
+ # SOURCED, NOT RUN, by `plot-worker-monitor.sh` and `plot-agent-monitor.sh`.
5
+ # Both need the same computation and neither renders it the same way, which is
6
+ # the same shape as `plot-worker-state.sh` and `plot-pr-merged.sh` — and the
7
+ # same reason. `plot-worker-state.sh` carried five of its six states in
8
+ # duplicate until 2026-08-18, and the copies had already drifted on the sixth.
9
+ # Two monitors deciding independently when to stop would drift the same way, and
10
+ # the failure would be silent: one monitor left running forever while its twin
11
+ # exits is exactly the leak this file exists to close, half-fixed.
12
+ #
13
+ # ═══════════════════════════════════════════════════════════════════════════
14
+ # WHY A MONITOR NEEDS THIS AT ALL
15
+ # ═══════════════════════════════════════════════════════════════════════════
16
+ #
17
+ # Measured 2026-08-30 and written up in
18
+ # `docs/research/2026-08-30-what-ends-a-monitor.md`: **nothing ended a monitor.**
19
+ # On an ordinary finish and on a `Worker bound` timeout alike, the wrapper's
20
+ # `wait "$agent"` returns, the wrapper writes `.plot-worker.exit` and exits, and
21
+ # both monitors are re-parented to `init` and loop forever. 34 of 40 monitors on
22
+ # the machine were `ppid=1` at the time of measurement, and 100 forks cost
23
+ # 23.3 ms against 4.8 ms on a quiet estate.
24
+ #
25
+ # The one run whose monitors WERE terminated is explained in that document and
26
+ # is not a mechanism: `nohup` does not `setsid`, so an orphan keeps the
27
+ # DISPATCHING SHELL's process group and a group kill sweeps it up collaterally.
28
+ # That fires when a human closes a terminal and never when a worker finishes —
29
+ # the opposite of a lifetime.
30
+ #
31
+ # ═══════════════════════════════════════════════════════════════════════════
32
+ # THE SUBJECT IS THE AGENT, AND THAT IS THE WHOLE DESIGN
33
+ # ═══════════════════════════════════════════════════════════════════════════
34
+ #
35
+ # A monitor exists to watch a dispatched agent. When that agent is gone there is
36
+ # nothing left to measure, so the monitor has finished its job rather than been
37
+ # interrupted — which is why this is a MEASUREMENT and not a timer.
38
+ #
39
+ # The plan forbids a timer explicitly, and the measurement says why: *"a monitor
40
+ # exiting after N seconds regardless would pass the visible assertions and
41
+ # destroy the property the whole plan rests on — a monitor that stops publishing
42
+ # means something."* A monitor that stops because its subject stopped carries
43
+ # information. One that stops because a clock ran out carries none, and is
44
+ # indistinguishable from one that crashed.
45
+ #
46
+ # THE AGENT COVERS ALL THREE ENDINGS. `--stop` kills the agent
47
+ # (`plot-dispatch.sh:752`); the `Worker bound` kills the agent
48
+ # (`plot-worker-loop.sh:172`); an ordinary finish is the agent exiting. In every
49
+ # case the wrapper survives just long enough to write `.plot-worker.exit` and
50
+ # then exits too. So watching the agent is sufficient, and watching the WRAPPER
51
+ # would be wrong: the wrapper outlives the agent by design, and a monitor bound
52
+ # to it would publish about a desk whose agent left.
53
+ #
54
+ # ═══════════════════════════════════════════════════════════════════════════
55
+ # AN ABSENT PID FILE IS `starting`, NEVER `gone`
56
+ # ═══════════════════════════════════════════════════════════════════════════
57
+ #
58
+ # `plot-dispatch.sh:478` records a sub-millisecond gap in which the wrapper has
59
+ # started and `.plot-worker.pid` is not yet written. The monitors start INSIDE
60
+ # that window — they are backgrounded before the agent, deliberately, so they
61
+ # exist before their subject does.
62
+ #
63
+ # So a monitor that read an absent pid file as `gone` would exit immediately on
64
+ # every single dispatch, and the leak would be replaced by a monitor that never
65
+ # runs. That is worse than the bug: an absent monitor is invisible, where an
66
+ # orphaned one at least shows up in `ps`.
67
+ #
68
+ # Three answers, not two:
69
+ #
70
+ # starting no pid file yet, or an unreadable one — the wrapper has not
71
+ # written it. Keep going; say nothing.
72
+ # alive the pid file names a process that exists. Keep going.
73
+ # gone the pid file names a process that does not exist. Stop.
74
+ #
75
+ # `starting` and `alive` are both "keep going", and they are kept apart anyway
76
+ # because the reason differs and a caller reporting them identically would lose
77
+ # the distinction the startup window depends on.
78
+ #
79
+ # ═══════════════════════════════════════════════════════════════════════════
80
+ # THE LOWER BOUND IS THE CALLER'S, AND IT IS AN ORDERING
81
+ # ═══════════════════════════════════════════════════════════════════════════
82
+ #
83
+ # The plan requires a monitor to outlive its agent long enough to record its
84
+ # finding — the Attaching slice's property, which this slice must not eat. This
85
+ # file does not enforce that, because it cannot: it answers a question and
86
+ # renders nothing.
87
+ #
88
+ # What enforces it is the ORDER in the callers: publish the pass, THEN ask.
89
+ # Every monitor therefore gets one final published pass after its agent has
90
+ # gone, which is the lower bound expressed as sequence rather than as a sleep.
91
+ # A caller that asked first and published second would satisfy the upper bound
92
+ # and silently lose the lower one — so the order is asserted in the tests, not
93
+ # left to a comment.
94
+
95
+ # `kill -0` is the liveness question, and it is the same one
96
+ # `plot-worker-state.sh` asks. It sends no signal; it only reports whether the
97
+ # pid can be signalled. A pid we do not own answers EPERM rather than ESRCH,
98
+ # which `kill -0` still reports as success — correct here, since a process we
99
+ # cannot signal is nonetheless a process that exists.
100
+ #
101
+ # `$1` = the path to the agent's pid file (`.plot-worker.pid`).
102
+ # Prints exactly one of: starting | alive | gone
103
+ plot_monitor_subject() {
104
+ local pid_file="${1:-}" pid
105
+
106
+ # No path at all: a hand-run monitor with no worktree, which has no subject to
107
+ # outlive and must not exit on its first pass. `starting` is the honest answer
108
+ # — there is nothing here that says the subject is gone.
109
+ [ -n "$pid_file" ] || { printf 'starting'; return 0; }
110
+
111
+ # NO PID FILE SPLITS TWO CASES, and reading them as one is what made monitors
112
+ # immortal. `starting` is right only while the desk is still there and the
113
+ # wrapper has not yet written the pid. If the DIRECTORY the pid file lives in
114
+ # is gone, the desk was removed — there is no subject to wait for and none is
115
+ # coming, so the honest answer is `gone`.
116
+ #
117
+ # Measured on CI 2026-08-31: 14 monitors at PPID 1, aged 11-13 minutes, each
118
+ # holding a `sleep 1`, after every test in the reconcile suite had PASSED.
119
+ # A test's fixture is removed at teardown, so its pid file vanishes BEFORE the
120
+ # agent does; `plot_monitor_wait` then never sees `gone` and loops forever,
121
+ # holding node's event loop open until the job ceiling kills it. That is the
122
+ # whole of the reconcile-suite hang, and it is why this is a two-case answer
123
+ # rather than one.
124
+ #
125
+ # PRODUCTION IS UNCHANGED: a real worktree outlives its agent, so the
126
+ # directory is present and this reads `starting` exactly as before.
127
+ if [ ! -f "$pid_file" ]; then
128
+ [ -d "$(dirname "$pid_file")" ] && { printf 'starting'; return 0; }
129
+ printf 'gone'; return 0
130
+ fi
131
+
132
+ pid=$(cat "$pid_file" 2>/dev/null | tr -d ' \n')
133
+
134
+ # A file that exists but holds no digits is a half-written pid, which is the
135
+ # startup window caught mid-`printf`. Not gone.
136
+ case "$pid" in
137
+ '' | *[!0-9]*) printf 'starting'; return 0 ;;
138
+ esac
139
+
140
+ if kill -0 "$pid" 2>/dev/null; then
141
+ printf 'alive'
142
+ else
143
+ printf 'gone'
144
+ fi
145
+ }
146
+
147
+ # Sleep up to `$1` seconds, but stop early the moment the subject at `$2` is
148
+ # gone. Returns 0 to publish another pass, 1 to leave.
149
+ #
150
+ # ═══════════════════════════════════════════════════════════════════════════
151
+ # WHY THE WAIT IS SPLIT WHEN THE PUBLISHING IS NOT
152
+ # ═══════════════════════════════════════════════════════════════════════════
153
+ #
154
+ # THE TWO CADENCES MUST STAY APART. The plan is explicit: the WorkerMonitor
155
+ # samples the process table every 30 s because a CPU delta is meaningless
156
+ # sampled further apart, and the AgentMonitor asks the host every 300 s because
157
+ # this repo has already measured what host questions on a fast loop cost. *"One
158
+ # subject wants tight sampling of a cheap fact; the other occasional sampling of
159
+ # an expensive one. Merging them would force one of those two to be wrong."*
160
+ #
161
+ # SO ONLY THE WAIT IS SPLIT, NEVER THE PASS. Publishing still happens on the
162
+ # monitor's own interval, unchanged — nothing here makes the AgentMonitor ask
163
+ # the host more often, and its 300 s stays 300 s. What is split is the IDLE TIME
164
+ # between passes, into short naps with a `kill -0` between them.
165
+ #
166
+ # WITHOUT THIS, THE UPPER BOUND IS THE INTERVAL. An AgentMonitor checking only
167
+ # after its full sleep would outlive an agent that finished in ten seconds by
168
+ # nearly five minutes. Bounded, technically — and still an orphan on every
169
+ # dispatch, on an estate where dispatches are frequent. The measurement that
170
+ # opened this slice counted 34 orphans; a five-minute window would have counted
171
+ # plenty too.
172
+ #
173
+ # THE PROBE IS FREE, WHICH IS WHY IT MAY BE FREQUENT. `kill -0` sends no signal
174
+ # and asks no host — it is a single syscall against the process table, the same
175
+ # question `plot-worker-state.sh` asks. The expensive half of an AgentMonitor
176
+ # pass is the host round trip, and that is in the PASS, not here.
177
+ #
178
+ # THE NAP IS THE GRANULARITY, and one second is chosen against the WorkerMonitor
179
+ # rather than in the abstract: a monitor may not outlive its agent by more than
180
+ # the tighter of the two cadences, or the fast monitor's exit would be slower
181
+ # than its own sampling. Any interval SHORTER than one nap sleeps once and is
182
+ # unaffected, which keeps `PLOT_MONITOR_INTERVAL=1` in a test behaving exactly
183
+ # as it reads.
184
+ plot_monitor_wait() { # $1 = seconds to wait, $2 = pid file
185
+ local remaining="${1:-0}" pid_file="${2:-}" nap
186
+ while [ "$remaining" -gt 0 ]; do
187
+ nap=1
188
+ [ "$remaining" -lt 1 ] && nap="$remaining"
189
+ sleep "$nap" || return 1
190
+ remaining=$((remaining - nap))
191
+ [ "$(plot_monitor_subject "$pid_file")" = gone ] && return 1
192
+ done
193
+ return 0
194
+ }