@plot-pm/board 0.13.0 → 0.14.1

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
@@ -46,6 +46,15 @@
46
46
  # deciding whether to REMOVE something and
47
47
  # silence is never permission. A call that
48
48
  # failed outright still exits 3.
49
+ # pr-merge-commit <branch> the merge commit sha of a merged PR for this
50
+ # branch, or EMPTY (exit 0) where none merged.
51
+ # The same query `pr-merged` runs, reading a
52
+ # second field of the one response. A separate
53
+ # subcommand because `pr-merged` prints one word
54
+ # and its callers read one word.
55
+ # EXIT 3 is the question failing, and it is not
56
+ # the same as an empty answer: a diff that could
57
+ # not be read is not a diff that was empty.
49
58
  # pr-create --title T [--body B] [--base BR] [--head BR] [--draft]
50
59
  # create a PR, print its URL
51
60
  # pr-merge <number> [--squash] [--delete-branch]
@@ -96,13 +105,25 @@
96
105
  # and a real failure presents identically.
97
106
  # Nothing here compares runs or concludes.
98
107
  # METERED: ask only for a branch already known
99
- # to be failing. Empty on bitbucket (bb has no
100
- # run listing) unavailable, never "never
101
- # failed".
108
+ # to be failing.
109
+ # DISPATCHED ON THE `CI` KEY, never on the git
110
+ # host: a build is the CI system's fact, and the
111
+ # two keys are independent. A repository that
112
+ # declared no CI, or one Plot has no connector
113
+ # for, EXITS 4 — not being able to ask is not an
114
+ # empty history.
115
+ # On jenkins it reports ONE current state as a
116
+ # history of one, with `startedAt` and `url`
117
+ # empty: `jen job list` carries no history and
118
+ # no timestamps, and inventing them would be a
119
+ # collector reaching a verdict.
102
120
  # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
103
121
  # run, with `sha` saying which it is — as a
104
122
  # single JSON object, or nothing when the branch
105
- # has no runs at all:
123
+ # has no runs at all. Dispatched on the `CI` key
124
+ # like `runs`, and EXITS 4 on jenkins: `jen job
125
+ # list` names no commit, so there is nothing to
126
+ # match a sha against. Output:
106
127
  # {"sha":"…","status":"queued|in_progress|
107
128
  # completed|waiting|requested",
108
129
  # "conclusion":"success|failure|…|null",
@@ -138,7 +159,26 @@
138
159
  # unavailable, never "no run".
139
160
  # issue-list [--limit N] open tracker issues as JSON lines:
140
161
  # {"number":N,"title":"…","url":"…",
141
- # "createdAt":"…"}
162
+ # "createdAt":"…","status":"…",
163
+ # "statusCategory":"…"}
164
+ # `status` is the tracker's OWN word for the
165
+ # stage — per-workflow, possibly localised
166
+ # (*Internal Approving*). `statusCategory` is
167
+ # the stable three-value vocabulary (To Do /
168
+ # In Progress / Done) a board may group on.
169
+ # BOTH, because neither substitutes for the
170
+ # other: the name is what a person reads, the
171
+ # category is what a board decides on. EVERY
172
+ # backend answers both — GitHub derives them
173
+ # from the `--state` this op asks for, jira
174
+ # reads .fields.status, and bitbucket maps the
175
+ # state badge it already parses. A Jira-only
176
+ # field is what this adapter exists to prevent.
177
+ # `statusCategory` is "" where the tracker's
178
+ # vocabulary has no word for it — bitbucket's
179
+ # ON HOLD/INVALID/DUPLICATE/WONTFIX. Empty is
180
+ # an honest answer; an invented category files
181
+ # abandoned work beside finished work.
142
182
  # READ-ONLY. Plot writes no label, no assignee
143
183
  # and no close-on-merge, because a copy of
144
184
  # tracker state ages into a lie. The one write
@@ -519,17 +559,141 @@ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
519
559
  # per row rather than blanking the entire list, and exit 3 is reserved for
520
560
  # when the operation itself cannot proceed.
521
561
 
522
- # Resolve CI backend: $PLOT_CI (for tests), then `CI` key, default "".
523
- # Non-jenkins values (github-actions, none, "") mean "no separate CI fetch".
524
- # The CI system this repo runs its builds on: `jenkins`, `github-actions`, or
525
- # `none`. Independent of the Git host a Bitbucket repo can build on Jenkins,
526
- # and this key is what pairs the two. `PLOT_CI` overrides for tests.
527
- ci_backend() {
562
+ # The raw `CI` declaration: `$PLOT_CI`, then the `CI` key, else empty.
563
+ #
564
+ # `PLOT_CI` IS A PRODUCTION CONTRACT, not a test override. The function this
565
+ # replaced called it *"overrides for tests"*, and that had been false since
566
+ # the build connectors landed: `build-actions.ts:39` passes
567
+ # `buildReads({ context, system: SYSTEM }, { PLOT_CI: SYSTEM })` as the dispatch
568
+ # contract the arm reads. It keeps its precedence and is split the same way, so
569
+ # a connector's bare `SYSTEM` word and a person's prose reach one comparison.
570
+ ci_raw() {
528
571
  if [ -n "${PLOT_CI:-}" ]; then
529
- printf '%s\n' "$PLOT_CI" | tr '[:upper:]' '[:lower:]'
572
+ printf '%s\n' "$PLOT_CI"
573
+ return
574
+ fi
575
+ bash "$here/plot-config.sh" get "CI" ""
576
+ }
577
+
578
+ # The CI system this repository declares — the FIRST token, lowercased.
579
+ #
580
+ # THE ONE READER OF THE `CI` KEY'S SCHEME, and every arm that dispatches on the
581
+ # CI system compares this. The function it replaced returned the WHOLE value
582
+ # while its four callers each matched a bare word (`jenkins`,
583
+ # `github-actions`), so a repository declaring
584
+ # `` CI: Jenkins at `jenkins-ci-ewz…` `` missed every arm and read
585
+ # `basis: unknown` where the Jenkins connector had `predicted: 60` ready.
586
+ # Measured 2026-09-09 against a reachable instance: only the bare word matched,
587
+ # and no repository on the estate writes it.
588
+ #
589
+ # THE PAYLOAD WAS THE SECOND HALF OF THAT COST. `ci-limit` reported the prose
590
+ # value as its `connector`, and `build-shell.ts` filters readings by equality
591
+ # against the connector's bare system word — so the reading was discarded before
592
+ # any caller saw it, and a metered Jenkins reported as metering nothing.
593
+ #
594
+ # Shaped after `tracker_scheme()` (`:1340`) — the same key packing a scheme
595
+ # ahead of its detail, split the same way. Empty when nothing is declared, and
596
+ # ABSENT IS NOT FALSE: `ci_unaskable()` reads an empty scheme as *this
597
+ # repository named no CI system*, which is a different answer from *this system
598
+ # has no connector*.
599
+ ci_scheme() {
600
+ ci_raw | awk '{print tolower($1)}'
601
+ }
602
+
603
+ # Whether a candidate token is a host `jen -I` could be given.
604
+ #
605
+ # A URL passes whole. Otherwise it must be dot-separated labels ending in an
606
+ # alphabetic TLD, carrying no path separator and no `@`.
607
+ #
608
+ # THE FINAL FILTER IS THE FILE EXTENSION, and that is a measurement rather than
609
+ # a preference. `ci.yml` and `package.json` both match the dotted-label pattern,
610
+ # while `.ch` and `.io` are real two-letter TLDs — so TLD LENGTH cannot separate
611
+ # a host from a filename in either direction, and a `CI:` value naming a
612
+ # workflow file (`github-actions (see .github/workflows/ci.yml)`) is the
613
+ # measured case that would otherwise be read as a server.
614
+ ci_looks_like_host() {
615
+ case "$1" in
616
+ https://*|http://*) return 0 ;;
617
+ */*|*@*) return 1 ;;
618
+ esac
619
+ printf '%s\n' "$1" \
620
+ | grep -qE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$' || return 1
621
+ case "$1" in
622
+ *.yml|*.yaml|*.json|*.md|*.sh|*.js|*.ts|*.mjs|*.cjs|*.toml|*.xml|*.txt|*.lock|*.gradle) return 1 ;;
623
+ esac
624
+ return 0
625
+ }
626
+
627
+ # Where this repository's CI lives: the `Jenkins instance` key, then
628
+ # `$JENKINS_INSTANCE`, then a host read out of the `CI` value. Empty when none
629
+ # of the three names one.
630
+ #
631
+ # THE ORDER IS NOT THE TRACKER'S, and the reason is information content.
632
+ # `tracker_base_url()` lets `$PLOT_JIRA_BASE_URL` win over its config value
633
+ # because both carry the same shape — a URL. Here the key carries MORE than the
634
+ # prose can express: `jenkins_instance()` (`:665`) returns `<slug>/<job/path>`,
635
+ # and the container path is a fact no file in a repository states
636
+ # (`stack.ts:98` calls the host *"the SLUG half"*). A prose-derived slug alone
637
+ # degrades `jenkins_build_map()` to root-scope listing, where its own comment
638
+ # says *"every branch reads `none`"* — and `quaweb` is nested
639
+ # (`job/quaweb/job/release`), so that renders an empty board for the exact
640
+ # repository this serves. So the explicit key is the primary and the prose is
641
+ # the fallback, never the other way round.
642
+ #
643
+ # IT EXTRACTS A HOST, NOT A REMAINDER. "Everything after the first token" was
644
+ # measured against all three real values and fails on every one:
645
+ # `at jenkins-ci-ewz… (Bitbucket PRs…`, `pipelines in .build/pipelines/ …`,
646
+ # `(e.g. continuous-build…)`. None is a host, and handing `jen -I` a sentence
647
+ # is worse than handing it nothing. `Tracker:` gets away with the naive split
648
+ # because its value is a scheme and a URL; `CI:` is prose in every repository
649
+ # that declares it.
650
+ #
651
+ # A BACKTICKED SPAN IS PREFERRED because the backticks are a deliberate act —
652
+ # the writer pointed at the value. It must still look like a host: one real
653
+ # repository marks up a PATH (`` `.build/pipelines/` ``), which is not a server.
654
+ #
655
+ # BACKTICKS ARE STRIPPED AND NO SCHEME IS NORMALISED ON. `ewz` writes a bare
656
+ # host in backticks and the probe used a full `https://` URL; `jen -I` accepts
657
+ # either, so inventing a canonical form would break whichever caller passes the
658
+ # other one.
659
+ #
660
+ # EMPTY IS AN ANSWER, not a failure: `Jenkins (e.g. continuous-build, …)` names
661
+ # no instance. Read the exit code, never the emptiness.
662
+ ci_instance() {
663
+ local instance candidate
664
+ instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
665
+ [ -n "$instance" ] || instance="${JENKINS_INSTANCE:-}"
666
+ if [ -n "$instance" ]; then
667
+ printf '%s' "$instance"
530
668
  return
531
669
  fi
532
- bash "$here/plot-config.sh" get "CI" "" | tr '[:upper:]' '[:lower:]'
670
+
671
+ local value
672
+ value="$(ci_raw)"
673
+
674
+ # The backticked spans, in order: `tr` puts each on its own line, so the
675
+ # even-numbered lines are what sat between a pair of backticks.
676
+ while IFS= read -r candidate; do
677
+ [ -n "$candidate" ] || continue
678
+ if ci_looks_like_host "$candidate"; then
679
+ printf '%s' "$candidate"
680
+ return
681
+ fi
682
+ done <<EOF
683
+ $(printf '%s\n' "$value" | tr '\`' '\n' | awk 'NR % 2 == 0')
684
+ EOF
685
+
686
+ # Otherwise the first bare token that looks like a host. Punctuation that
687
+ # ends a clause is split off so `(jenkins.acme.dev)` reads as the host.
688
+ while IFS= read -r candidate; do
689
+ [ -n "$candidate" ] || continue
690
+ if ci_looks_like_host "$candidate"; then
691
+ printf '%s' "$candidate"
692
+ return
693
+ fi
694
+ done <<EOF
695
+ $(printf '%s\n' "$value" | tr -d '\`' | tr ' \t,()[]<>"'"'" '\n')
696
+ EOF
533
697
  }
534
698
 
535
699
  # Fetch Jenkins build statuses for every branch in a multibranch job, in ONE
@@ -634,6 +798,97 @@ jenkins_build_map() {
634
798
  '
635
799
  }
636
800
 
801
+ # Where this repository's Jenkins lives — the `Jenkins instance` key, else
802
+ # `JENKINS_INSTANCE`. Prints nothing when neither is set.
803
+ #
804
+ # Read through ONE function because three ops now ask it. `pr-list --rich` read
805
+ # it inline first; `runs` and `run-for-sha` ask the same question and must get
806
+ # the same answer and the same refusal, or a repository would be configured for
807
+ # one op and not the others.
808
+ jenkins_instance() {
809
+ local instance
810
+ instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
811
+ [ -n "$instance" ] || instance="${JENKINS_INSTANCE:-}"
812
+ printf '%s' "$instance"
813
+ }
814
+
815
+ # The refusal a Jenkins op prints when it has no instance to ask, naming the
816
+ # three repairs. Exit 3 — the op itself cannot proceed, which is a config error
817
+ # only a person can fix, and deliberately NOT exit 4: a `CI: jenkins` repository
818
+ # CAN be asked about builds, it just has not said where.
819
+ jenkins_no_instance() {
820
+ echo "plot-host: CI is jenkins but no Jenkins instance is configured" >&2
821
+ echo " Add a 'Jenkins instance' key to the ## Plot Config section of CLAUDE.md," >&2
822
+ echo " naming the instance \`jen\` knows, or set JENKINS_INSTANCE." >&2
823
+ echo " Or drop the 'CI: jenkins' key to read build status from the git host." >&2
824
+ exit 3
825
+ }
826
+
827
+ # NOT BEING ABLE TO ASK IS NOT AN EMPTY ANSWER, and exit 4 is where the two are
828
+ # kept apart. An empty run list means *this branch has no runs*; a repository
829
+ # that declared no CI system has nothing that could hold one, and a caller
830
+ # reading `[]` for that would report *nothing has ever failed here* about a
831
+ # question nobody asked.
832
+ #
833
+ # `ci-limit`'s `*)` arm already draws this line for a connector nobody wrote an
834
+ # estimate for. This draws it one step earlier, for a connector that does not
835
+ # exist at all — and `resultOf` (`adapters/run-script.ts:211`) maps exit 4 onto
836
+ # `unaskable`, which is the word the build port already answers with.
837
+ # The Jenkins REST credential, read from where `jen` already stores it.
838
+ #
839
+ # NOT THE KEYCLOAK BEARER. `jen auth token` prints one and Jenkins answers it
840
+ # with an HTML login redirect — measured 2026-09-10, and it is the trap that
841
+ # made an earlier reading conclude the sha was unreachable. Jenkins' own API
842
+ # takes BASIC auth with a user and an API token, which `jen` keeps in the login
843
+ # keychain under service `jen`, accounts `jenkins-user:<host>` and
844
+ # `jenkins-token:<host>`.
845
+ #
846
+ # IT PRINTS `user:token` AND NOTHING ELSE, or nothing at all. The caller passes
847
+ # it straight to `curl -u`; no branch echoes it, and no failure path names it.
848
+ #
849
+ # EMPTY IS THE HONEST ANSWER on a machine with no `security` (Linux, CI), with
850
+ # no keychain entry, or with either half missing — a half credential is not a
851
+ # credential. The caller turns that into exit 4, which is *cannot be asked*.
852
+ jenkins_rest_credential() { # $1 = the bare host
853
+ command -v security >/dev/null 2>&1 || return 1
854
+ local u t
855
+ u=$(security find-generic-password -s jen -a "jenkins-user:$1" -w 2>/dev/null) || return 1
856
+ t=$(security find-generic-password -s jen -a "jenkins-token:$1" -w 2>/dev/null) || return 1
857
+ [ -n "$u" ] && [ -n "$t" ] || return 1
858
+ printf '%s:%s' "$u" "$t"
859
+ }
860
+
861
+ # A job path as Jenkins' REST API spells it: every segment under `job/`.
862
+ #
863
+ # `quaweb/release` is the path a person writes and `job/quaweb/job/release` is
864
+ # the URL, because a Jenkins folder is itself a job. A multibranch branch adds
865
+ # one more segment, PERCENT-ENCODED — `bug/foo` is `bug%2Ffoo`, the same
866
+ # encoding `jenkins_build_map` already decodes on the way back.
867
+ jenkins_job_url_path() { # $1 = job path, $2 = branch or ""
868
+ local out="" seg
869
+ local IFS=/
870
+ for seg in $1; do [ -n "$seg" ] && out="$out/job/$seg"; done
871
+ unset IFS
872
+ if [ -n "${2:-}" ]; then
873
+ out="$out/job/$(printf '%s' "$2" | sed 's|/|%2F|g')"
874
+ fi
875
+ printf '%s' "$out"
876
+ }
877
+
878
+ ci_unaskable() { # $1 = the op's name, $2 = the CI word (may be empty)
879
+ local ci_word="${2:-}"
880
+ if [ -z "$ci_word" ] || [ "$ci_word" = none ]; then
881
+ echo "plot-host: $1 — this repository declares no CI system, so there is nothing to ask" >&2
882
+ echo " Add a 'CI' key to the ## Plot Config section of CLAUDE.md naming the" >&2
883
+ echo " system that builds this repository — \`github-actions\` or \`jenkins\`." >&2
884
+ else
885
+ echo "plot-host: $1 — no connector for CI system '$ci_word'" >&2
886
+ echo " Plot answers this op for \`github-actions\` and \`jenkins\`. An unknown" >&2
887
+ echo " system cannot be asked, which is not the same as having no runs." >&2
888
+ fi
889
+ exit 4
890
+ }
891
+
637
892
  # A LOOKUP MISS AND A TRANSPORT FAILURE ARE TWO ANSWERS, AND THE CLI GIVES ONE
638
893
  # EXIT CODE FOR BOTH.
639
894
  #
@@ -1280,6 +1535,60 @@ tracker_base_url() {
1280
1535
  tracker_raw | awk '{print $2}' | sed 's:/*$::'
1281
1536
  }
1282
1537
 
1538
+ # The Jira projects this repository's work lives in, one per line, or nothing.
1539
+ #
1540
+ # WHY A KEY AND NOT A QUERY. The inbox's default JQL scopes by PERSON
1541
+ # (`assignee = currentUser()`) and by STATE (`resolution = EMPTY`), and by
1542
+ # nothing else. On a shared Jira instance that is instance-wide: one reporter's
1543
+ # board showed twelve issues, of which one belonged to a different customer
1544
+ # entirely. Jira has no notion of the repository a board serves — no
1545
+ # `currentProject()` function exists — and that mapping lives only here, in this
1546
+ # repository's own config. Asking Jira which projects the user can see answers
1547
+ # the wrong question: that list is instance-wide too, and is what produced the
1548
+ # bug.
1549
+ #
1550
+ # IT HOLDS A LIST, and the single prefix adoption seeds only STARTS it.
1551
+ # `plot-detect-repo.sh` takes `head -1`, so a freshly adopted repo declares one
1552
+ # project. Filtering on that alone would be a second defect wearing the fix's
1553
+ # clothes — on the reported repository it shows 3 of 12 issues and hides the
1554
+ # other two projects' work under a heading claiming nobody had planned it. A
1555
+ # repository mapping to several Jira projects is the normal case.
1556
+ #
1557
+ # NAMED `Ticket prefixes`, not `Jira projects` (which puts a vendor in a config
1558
+ # key and severs the word the probe already uses) and not `Tracker projects`
1559
+ # (which generalises over a set of one). It sits next to `Branch prefixes` in
1560
+ # every adopting repo's config — an unrelated structural key holding `idea/`,
1561
+ # `feature/`, `bug/` — and only this docstring keeps the two apart.
1562
+ #
1563
+ # EMPTY IS THE ANSWER FOR AN UNDECLARED KEY, and callers must keep it meaning
1564
+ # *do not scope*: the absent case has to leave today's query untouched, or an
1565
+ # upgrade empties every existing board's inbox — a worse failure than the one
1566
+ # being fixed, because it looks like *no tickets* rather than the wrong ones.
1567
+ #
1568
+ # PARSING IS STATED HERE, not inherited: this is the estate's first list-valued
1569
+ # key with a consumer (`Implementation home` is documented as taking a list and
1570
+ # nothing splits one). Commas separate, surrounding whitespace is not part of a
1571
+ # value, and an empty element is dropped rather than emitted — a join over a
1572
+ # stray comma would otherwise produce `IN (PROJ-A,)`, which Jira rejects. A
1573
+ # value that is punctuation alone therefore reads as *undeclared*: it restores
1574
+ # the unscoped query rather than sending a query no instance would accept.
1575
+ #
1576
+ # `PLOT_TICKET_PREFIXES` overrides, the shape `PLOT_TRACKER` already sets. It
1577
+ # cannot express ABSENCE — an empty override falls through to the config — so
1578
+ # the absent case is tested with a repository that declares nothing.
1579
+ tracker_projects() {
1580
+ local raw
1581
+ if [ -n "${PLOT_TICKET_PREFIXES:-}" ]; then
1582
+ raw="$PLOT_TICKET_PREFIXES"
1583
+ else
1584
+ raw="$(bash "$here/plot-config.sh" get "Ticket prefixes" "")"
1585
+ fi
1586
+ # One per line, trimmed, empties dropped. `plot-config.sh` already normalises
1587
+ # `A,B` to `A, B`, but an override arrives unnormalised and a hand-edited
1588
+ # value can carry a stray comma — so the split is done here regardless.
1589
+ printf '%s' "$raw" | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | grep -v '^$' || true
1590
+ }
1591
+
1283
1592
  # The env var scheme for Jira auth. The plan left the EXACT names open, to be
1284
1593
  # confirmed against a real instance; these follow Jira Cloud's documented Basic
1285
1594
  # scheme (email + API token, base64'd into an Authorization header):
@@ -1454,12 +1763,47 @@ backend_declared() {
1454
1763
  return
1455
1764
  fi
1456
1765
  local v
1457
- v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
1766
+ v="$(bash "$here/plot-config.sh" get "Git host" "" | tr '[:upper:]' '[:lower:]')"
1458
1767
  case "$v" in
1459
- bb) echo "bitbucket" ;;
1460
- "") echo "github" ;;
1461
- *) printf '%s\n' "$v" ;;
1768
+ bb) echo "bitbucket"; return ;;
1769
+ "") : ;;
1770
+ *) printf '%s\n' "$v"; return ;;
1771
+ esac
1772
+
1773
+ # NOTHING DECLARED — INFER FROM THE REMOTE, AND REFUSE WHERE THERE IS NONE.
1774
+ # This answered `github` unconditionally until 2026-09-11, which is the
1775
+ # reassuring direction: a Bitbucket repo that forgot the key got GitHub's
1776
+ # answer, and a repo with no remote at all got one too. Measured on a fresh
1777
+ # `git init` with no remote: `backend` printed `github` and exited 0.
1778
+ #
1779
+ # THE REMOTE IS EVIDENCE AND THE KEY IS A DECLARATION, so the key still wins
1780
+ # above. What changes is only the case where nobody said: a hostname in the
1781
+ # remote is a reading, and no remote is no reading.
1782
+ local url
1783
+ url="$(git config --get remote.origin.url 2>/dev/null || true)"
1784
+ case "$url" in
1785
+ *github.com*) echo "github"; return ;;
1786
+ *bitbucket.org*) echo "bitbucket"; return ;;
1462
1787
  esac
1788
+
1789
+ # NOTHING NAMES A HOST, AND THAT IS REPORTED RATHER THAN REFUSED. An earlier
1790
+ # version of this returned exit 4 here, and five contract tests went red:
1791
+ # a sandbox repository with no remote is a legitimate, common shape — six
1792
+ # suites build one — and every op that needs a host in one was relying on
1793
+ # this default. Refusing at the resolver punishes them for a question they
1794
+ # never asked.
1795
+ #
1796
+ # SO THE GUESS SURVIVES AND STOPS BEING SILENT — AND IT SIGNALS THROUGH THE
1797
+ # EXIT CODE, NEVER A VARIABLE. This set a `BACKEND_UNNAMED` global first, and
1798
+ # the caller reads it as `v="$(backend_declared)"` — a COMMAND SUBSTITUTION,
1799
+ # which runs in a subshell, so the assignment died with the child and the
1800
+ # parent always read 0. The warning never printed, and a direct call looked
1801
+ # correct because its stdout was right.
1802
+ #
1803
+ # Exit 9 is arbitrary and deliberately outside the contract's 0/1/3/4: it
1804
+ # never leaves this file, and `backend` maps it back to a successful answer.
1805
+ echo "github"
1806
+ return 9
1463
1807
  }
1464
1808
 
1465
1809
  # The resolved backend, refused where this script has no arm for it.
@@ -1472,12 +1816,27 @@ backend_declared() {
1472
1816
  # person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
1473
1817
  # sentence below for the name.
1474
1818
  backend() {
1475
- local v
1476
- v="$(backend_declared)" || return 1
1819
+ local v rc unnamed=0
1820
+ # THE DECLARED-HOST REFUSAL IS PASSED THROUGH, NOT FLATTENED. `|| return 1`
1821
+ # collapsed exit 4 into 1 here, and 4 is the one code every caller reads as
1822
+ # *this cannot be asked at all* rather than *retry*. Measured 2026-09-11: a
1823
+ # repository with no remote exited 1, which tells a caller to try again.
1824
+ v="$(backend_declared)"; rc=$?
1825
+ # 9 is the resolver's private word for *answered, but nothing named it*.
1826
+ if [ "$rc" -eq 9 ]; then unnamed=1; rc=0; fi
1827
+ [ "$rc" -eq 0 ] || return "$rc"
1477
1828
  if ! host_drivable "$v"; then
1478
1829
  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
1830
  return 4
1480
1831
  fi
1832
+ # THE ANSWER IS PRINTED EITHER WAY, AND A GUESS SAYS SO. `backend` is a
1833
+ # reading, not a gate: a caller that needs a host still gets one, and a
1834
+ # person asking which host this is learns the answer was inferred from
1835
+ # nothing. Exit stays 0 — the value is usable, its provenance is not certain.
1836
+ if [ "$unnamed" = 1 ]; then
1837
+ echo "plot-host: no 'Git host' key and no remote names one — assuming '$v'" >&2
1838
+ echo " Set the 'Git host' key in ## Plot Config (or \$PLOT_HOST) to be sure." >&2
1839
+ fi
1481
1840
  printf '%s\n' "$v"
1482
1841
  }
1483
1842
 
@@ -2163,6 +2522,73 @@ case "$op" in
2163
2522
  fi
2164
2523
  ;;
2165
2524
 
2525
+ pr-merge-commit)
2526
+ # THE MERGE COMMIT OF A BRANCH'S MERGED PR, and nothing else.
2527
+ #
2528
+ # A SECOND SUBCOMMAND RATHER THAN A FIELD ON `pr-merged`, because that one
2529
+ # prints ONE WORD and eleven callers read it as one — `plot-reap.sh`,
2530
+ # `plot-release-refs.sh` and `plot-dispatch.sh` among them, each deciding
2531
+ # whether to remove something. Widening its output to carry a sha would
2532
+ # rewrite a contract those callers depend on, to serve a question none of
2533
+ # them asks.
2534
+ #
2535
+ # THE QUERY IS `pr-merged`'s, so a caller asking both spends TWO calls where
2536
+ # one would do — and that is a cost this path does not pay: delivery asks
2537
+ # this only for a branch it has already been told merged, and the plan it
2538
+ # serves budgets one host call per branch either way.
2539
+ #
2540
+ # `--state all` and `--limit 100` for `pr-merged`'s own two reasons: a
2541
+ # merged PR reports CLOSED, and the newest PR is not the merge.
2542
+ #
2543
+ # PRINTS NOTHING AND EXITS 0 where no PR merged — an ANSWER, the same shape
2544
+ # `pr-state` gives for a missing PR. Exit 3 is the question failing.
2545
+ ref="${1:?pr-merge-commit needs a branch}"; shift || true
2546
+ repo_args=()
2547
+ while [ $# -gt 0 ]; do
2548
+ case "$1" in
2549
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
2550
+ *) die "pr-merge-commit: unknown arg $1" ;;
2551
+ esac
2552
+ done
2553
+ if [ "$be" = "github" ]; then
2554
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt,mergeCommit 2>/tmp/plot-host-err.$$)"; then
2555
+ rm -f "/tmp/plot-host-err.$$"
2556
+ # The FIRST merged PR carrying a sha. A branch may hold several merged
2557
+ # PRs; each names its own merge commit, and any of them is a commit that
2558
+ # landed this branch's work.
2559
+ jq -r 'map(select(.mergedAt != null and .mergeCommit != null))
2560
+ | map(.mergeCommit.oid) | first // empty' <<<"$out"
2561
+ else
2562
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2563
+ # A lookup miss is an answer: no PR, so no merge commit. Anything else
2564
+ # is the question failing, and silence must not read as "carried
2565
+ # nothing" — the caller distinguishes the two by the exit code.
2566
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2567
+ :
2568
+ else
2569
+ echo "plot-host: $err" >&2
2570
+ exit 3
2571
+ fi
2572
+ fi
2573
+ else
2574
+ bb_require_json
2575
+ # Bitbucket names the merge commit `merge_commit.hash` on a merged PR.
2576
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
2577
+ rm -f "/tmp/plot-host-err.$$"
2578
+ jq -r --arg b "$ref" 'map(select(.source.branch.name==$b))
2579
+ | map(.merge_commit.hash // empty) | first // empty' <<<"$out"
2580
+ else
2581
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2582
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2583
+ :
2584
+ else
2585
+ echo "plot-host: $err" >&2
2586
+ exit 3
2587
+ fi
2588
+ fi
2589
+ fi
2590
+ ;;
2591
+
2166
2592
  pr-create)
2167
2593
  title=""; body=""; base=""; head=""; draft=0
2168
2594
  while [ $# -gt 0 ]; do
@@ -2276,19 +2702,12 @@ case "$op" in
2276
2702
  # the board rejects a non-zero pr-list and keeps its last good map; one
2277
2703
  # dead Jenkins must not darken every row. This reconciles Done-when 4's
2278
2704
  # "exits 3" with the brief's "prefer unknown on the affected rows".
2279
- ci="$(ci_backend)"
2705
+ ci="$(ci_scheme)"
2280
2706
  jen_map=""
2281
2707
  jen_status=""
2282
2708
  if [ "$ci" = "jenkins" ] && [ "$rich" = 1 ]; then
2283
- jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
2284
- [ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
2285
- if [ -z "$jen_instance" ]; then
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
2291
- fi
2709
+ jen_instance="$(jenkins_instance)"
2710
+ [ -n "$jen_instance" ] || jenkins_no_instance
2292
2711
  jen_payload=$(jenkins_build_map "$jen_instance")
2293
2712
  jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
2294
2713
  jen_map=$(printf '%s' "$jen_payload" | jq -c '.map // {}' 2>/dev/null || echo "{}")
@@ -2543,9 +2962,13 @@ case "$op" in
2543
2962
  # and failing branches are rare by construction; a caller that asked for
2544
2963
  # every branch would spend a budget the board has already exhausted once.
2545
2964
  #
2546
- # Bitbucket reports nothing here rather than something invented. `bb` has no
2547
- # run listing, and an empty history renders as "unavailable" never as
2548
- # "this branch has never failed before".
2965
+ # IT DISPATCHES ON THE CI SYSTEM, NEVER ON THE GIT HOST. This arm gated on
2966
+ # `be` until 2026-09-08, which made this repository's accidentGitHub is
2967
+ # both the shape of the op: a Bitbucket team building on Jenkins got
2968
+ # silence, and a GitHub team building on Jenkins got GitHub Actions runs
2969
+ # for a repository whose CI is not GitHub Actions. `CI` and `Git host` are
2970
+ # independent `## Plot Config` keys, and this is the op that reads the
2971
+ # first of them.
2549
2972
  branch="${1:?runs needs a branch}"; shift
2550
2973
  limit=10
2551
2974
  while [ $# -gt 0 ]; do
@@ -2554,13 +2977,61 @@ case "$op" in
2554
2977
  *) die "runs: unknown arg $1" ;;
2555
2978
  esac
2556
2979
  done
2557
- if [ "$be" = "github" ]; then
2558
- gh run list --branch "$branch" --limit "$limit" \
2559
- --json workflowName,conclusion,status,startedAt,url 2>/dev/null \
2560
- | jq -c '.[] | {workflow:.workflowName,
2561
- conclusion:(if (.conclusion // "") == "" then .status else .conclusion end),
2562
- startedAt:.startedAt, url:.url}' 2>/dev/null || true
2563
- fi
2980
+ _ci="$(ci_scheme)"
2981
+ case "$_ci" in
2982
+ github-actions)
2983
+ # THE GIT HOST STILL HAS TO BE GITHUB, and that is a second condition
2984
+ # rather than the same one. `gh run list` reads the runs of the
2985
+ # repository the CWD's remote names, so a repository declaring
2986
+ # `CI: github-actions` on a Bitbucket remote has declared something
2987
+ # `gh` cannot reach — an unaskable configuration, not an empty history.
2988
+ if [ "$be" != "github" ]; then
2989
+ echo "plot-host: runs — CI is github-actions but the git host is '$be'" >&2
2990
+ echo " \`gh run list\` reads the runs of the repository its remote names," >&2
2991
+ echo " so there is no GitHub repository here to ask about." >&2
2992
+ exit 4
2993
+ fi
2994
+ gh run list --branch "$branch" --limit "$limit" \
2995
+ --json workflowName,conclusion,status,startedAt,url 2>/dev/null \
2996
+ | jq -c '.[] | {workflow:.workflowName,
2997
+ conclusion:(if (.conclusion // "") == "" then .status else .conclusion end),
2998
+ startedAt:.startedAt, url:.url}' 2>/dev/null || true
2999
+ ;;
3000
+ jenkins)
3001
+ # ONE STATE PER BRANCH, REPORTED AS A HISTORY OF ONE — and the PR says
3002
+ # so, because it is the honest limit of the transport this arm has.
3003
+ # `jenkins_build_map` answers `{color, checks, job}` for every branch in
3004
+ # one call: a CURRENT state, with no timestamps, no URL and no earlier
3005
+ # runs. A history is what Jenkins holds and what its REST API answers;
3006
+ # reading it is `the-ci-connector-is-jenkins`'s own slice, and inventing
3007
+ # entries here to fill the shape would be the collector reaching a
3008
+ # verdict (Principle 3).
3009
+ #
3010
+ # SO THE FIELDS THIS CANNOT MEASURE ARE EMPTY, never guessed. `workflow`
3011
+ # names the Jenkins job, which is the one thing the map does carry and
3012
+ # the detail a reader opens; `conclusion` is the `checks` word the
3013
+ # colour table already settled; `startedAt` and `url` are empty,
3014
+ # because a timestamp this arm invented would read as measurement.
3015
+ _jen_instance="$(jenkins_instance)"
3016
+ [ -n "$_jen_instance" ] || jenkins_no_instance
3017
+ _jen_payload=$(jenkins_build_map "$_jen_instance")
3018
+ _jen_status=$(printf '%s' "$_jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
3019
+ if [ "$_jen_status" != "ok" ]; then
3020
+ # UNREACHABLE IS NOT EMPTY EITHER. `pr-list --rich` can mark its rows
3021
+ # `unknown` and keep them; this op has no row to carry the word, so
3022
+ # the only way to say *cannot verify* is the exit code. Printing
3023
+ # nothing at exit 0 would read as *this branch has never run*.
3024
+ echo "plot-host: runs — jenkins unreachable ($_jen_status)" >&2
3025
+ exit 4
3026
+ fi
3027
+ printf '%s' "$_jen_payload" | jq -c --arg branch "$branch" \
3028
+ '.map[$branch] // empty
3029
+ | {workflow:(.job // ""), conclusion:(.checks // ""), startedAt:"", url:""}' 2>/dev/null || true
3030
+ ;;
3031
+ *)
3032
+ ci_unaskable runs "$_ci"
3033
+ ;;
3034
+ esac
2564
3035
  ;;
2565
3036
 
2566
3037
  run-for-sha)
@@ -2606,39 +3077,151 @@ case "$op" in
2606
3077
  *) die "run-for-sha: unknown arg $1" ;;
2607
3078
  esac
2608
3079
  done
2609
- if [ "$be" = "github" ]; then
2610
- # `headSha` is the field that makes this answerable at all; `runs` omits
2611
- # it, which is why that op cannot be reused here.
2612
- #
2613
- # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
2614
- # newest-first, and a sha can carry several (a rerun, or several
2615
- # workflows). The newest is the live answer; older ones for the same sha
2616
- # are superseded by the same argument that superseded runs for older shas.
2617
- # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
2618
- # and `sha` in the output says WHICH, because a caller that could not tell
2619
- # the two apart would be back to the branch-scoped guessing this op exists
2620
- # to end.
2621
- #
2622
- # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
2623
- # the asked-for sha and stopping makes the most important case invisible:
2624
- # a run IN FLIGHT for a commit the branch has already moved past reports
2625
- # identically to no run at all, so a caller cannot distinguish *CI has not
2626
- # started yet* from *CI is busy answering about the past*. The second is
2627
- # the state that had two merge waiters reporting on superseded runs on
2628
- # 2026-08-30, and it is exactly what a caller needs to see.
2629
- #
2630
- # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
2631
- # the sha that run is for; whether that sha being different from the one
2632
- # asked about means "superseded" is the caller's rule. This collects.
2633
- gh run list --branch "$branch" --limit "$limit" \
2634
- --json headSha,conclusion,status,startedAt,url 2>/dev/null \
2635
- | jq -c --arg sha "$sha" \
2636
- '(map(select(.headSha == $sha)) | .[0]) // .[0]
2637
- | select(. != null)
2638
- | {sha:.headSha, status:.status,
2639
- conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
2640
- url:.url, startedAt:.startedAt}' 2>/dev/null || true
3080
+ # DISPATCHED ON THE CI SYSTEM, for the reason `runs` above states: the git
3081
+ # host is a separate key, and a build is the CI system's fact.
3082
+ _ci="$(ci_scheme)"
3083
+ case "$_ci" in
3084
+ jenkins)
3085
+ # THE SHA IS IN JENKINS AND `jen` IS NOT THE TRANSPORT. Measured
3086
+ # 2026-09-10 against a live instance: a build entry from `jen build
3087
+ # list --json` carries `id`, `status`, timings and stages, and a search
3088
+ # of the whole payload for `sha|commit|revision|scm` matches nothing.
3089
+ # `jen` has no changesets subcommand and no raw-API passthrough.
3090
+ #
3091
+ # JENKINS' OWN REST API ANSWERS IT, at
3092
+ # `actions[].lastBuiltRevision.SHA1`, paired with the branch. One
3093
+ # `tree=` query returns a whole history 4855 bytes for five builds —
3094
+ # so this costs one round trip like the GitHub arm does.
3095
+ #
3096
+ # THIS ARM EXITED 4 UNTIL 2026-09-11, and the refusal was honest for
3097
+ # the transport it had. What changed is the transport, not the rule.
3098
+ _jen_instance="$(jenkins_instance)"
3099
+ [ -n "$_jen_instance" ] || jenkins_no_instance
3100
+ _jen_host="${_jen_instance%%/*}"
3101
+ _jen_job="${_jen_instance#*/}"
3102
+ [ "$_jen_job" = "$_jen_instance" ] && _jen_job=""
3103
+ [ -n "${PLOT_JENKINS_JOB:-}" ] && _jen_job="$PLOT_JENKINS_JOB"
3104
+ if [ -z "$_jen_job" ]; then
3105
+ # A bare-host instance names no job, and a sha lives in a job's
3106
+ # builds. `runs` degrades to an empty map here; this op has no row to
3107
+ # carry that, so the only way to say *cannot be asked* is exit 4.
3108
+ echo "plot-host: run-for-sha — the Jenkins instance names no job path" >&2
3109
+ echo " A sha is a fact about a job's builds, so the instance must be" >&2
3110
+ echo " <slug>/<job/path> rather than a bare host." >&2
3111
+ exit 4
3112
+ fi
3113
+ _jen_cred="$(jenkins_rest_credential "$_jen_host")" || {
3114
+ # NO CREDENTIAL IS *CANNOT BE ASKED*, never *no run*. The keychain is
3115
+ # macOS-only and a Linux agent legitimately has none; saying nothing
3116
+ # at exit 0 would read as a branch that never built.
3117
+ echo "plot-host: run-for-sha — no Jenkins API credential for '$_jen_host'" >&2
3118
+ echo " Jenkins' REST API takes basic auth with an API token, which" >&2
3119
+ echo " \`jen auth login\` stores in the login keychain. The Keycloak" >&2
3120
+ echo " bearer from \`jen auth token\` is NOT it — Jenkins answers that" >&2
3121
+ echo " with a login redirect." >&2
3122
+ exit 4
3123
+ }
3124
+ # A BRANCH IS A JOB SEGMENT ON A MULTIBRANCH JOB AND NOT ON A PLAIN
3125
+ # ONE, and no reading of the configured path says which this is. So the
3126
+ # multibranch URL is tried first and the plain one is the fallback:
3127
+ # `job/quaweb/job/continuous-build/job/content%2Fctas` against
3128
+ # `job/quaweb/job/release`. Measured 2026-09-11 — asking the plain job
3129
+ # for a branch segment answers 404, which is why guessing one shape
3130
+ # cost a run.
3131
+ #
3132
+ # THE PLAIN JOB REPORTS ITS OWN BUILDS whatever branch was asked about,
3133
+ # and that is honest rather than wrong: a pipeline job builds one
3134
+ # thing, and the `sha` in the answer says which commit it built.
3135
+ _jen_tree="tree=builds%5Bnumber,result,building,timestamp,url,actions%5BlastBuiltRevision%5BSHA1%5D%5D%5D%7B0,$limit%7D"
3136
+ _jen_body=""
3137
+ for _jen_path in \
3138
+ "$(jenkins_job_url_path "$_jen_job" "$branch")" \
3139
+ "$(jenkins_job_url_path "$_jen_job" "")"; do
3140
+ host_slot_take jenkins ''
3141
+ _jen_try=$(curl -sg --max-time 30 -u "$_jen_cred" \
3142
+ "https://$_jen_host$_jen_path/api/json?$_jen_tree" 2>/dev/null) || true
3143
+ host_slot_give
3144
+ budget_record_call jenkins ''
3145
+ if printf '%s' "$_jen_try" | jq -e 'has("builds")' >/dev/null 2>&1; then
3146
+ _jen_body="$_jen_try"; break
3147
+ fi
3148
+ done
3149
+ _jen_cred=""
3150
+ if [ -z "$_jen_body" ] || ! printf '%s' "$_jen_body" | jq -e 'has("builds")' >/dev/null 2>&1; then
3151
+ # AN UNREACHABLE INSTANCE IS NOT AN EMPTY HISTORY. A refused or
3152
+ # redirected request answers HTML, which fails the `builds` test.
3153
+ echo "plot-host: run-for-sha — Jenkins did not answer for '$_jen_host'" >&2
3154
+ exit 4
3155
+ fi
3156
+ # THE SAME FALLBACK RULE AS THE GITHUB ARM, and it is inherited rather
3157
+ # than invented: the asked-for sha if a build carries it, else the
3158
+ # newest build, and `sha` says WHICH. A caller that could not tell the
3159
+ # two apart would be back to the branch-scoped guessing this op ends.
3160
+ #
3161
+ # `result` is null while a build runs, which is Jenkins' own word for
3162
+ # *in flight* — mapped to the `status`/`conclusion` split the contract
3163
+ # already uses, so the shape does not fork per connector.
3164
+ printf '%s' "$_jen_body" | jq -c --arg sha "$sha" '
3165
+ [ .builds[]
3166
+ | { sha: ([ .actions[]? | select(.lastBuiltRevision) | .lastBuiltRevision.SHA1 ] | first // ""),
3167
+ status: (if .building then "in_progress" else "completed" end),
3168
+ conclusion: (if .building then null else (.result // null) end),
3169
+ url: (.url // ""),
3170
+ startedAt: (if .timestamp then (.timestamp / 1000 | todate) else "" end) } ]
3171
+ | ((map(select(.sha == $sha)) | .[0]) // .[0])
3172
+ | select(. != null)' 2>/dev/null || true
3173
+ # THIS ARM ANSWERS AND THE OP IS OVER. Everything below the `esac` is
3174
+ # the GitHub path — the old jenkins arm reached it only because it
3175
+ # ended in `exit 4`. Measured 2026-09-11: without this the answer was
3176
+ # printed and then a `CI is github-actions but the git host is
3177
+ # 'bitbucket'` refusal followed it on stderr.
3178
+ exit 0
3179
+ ;;
3180
+ github-actions) : ;;
3181
+ *) ci_unaskable run-for-sha "$_ci" ;;
3182
+ esac
3183
+ if [ "$be" != "github" ]; then
3184
+ # SAME SECOND CONDITION AS `runs`: `gh run list` reads the repository its
3185
+ # remote names, so `CI: github-actions` on a non-GitHub remote names runs
3186
+ # that cannot be reached from here.
3187
+ echo "plot-host: run-for-sha — CI is github-actions but the git host is '$be'" >&2
3188
+ echo " \`gh run list\` reads the runs of the repository its remote names," >&2
3189
+ echo " so there is no GitHub repository here to ask about." >&2
3190
+ exit 4
2641
3191
  fi
3192
+ # From here the CI system is `github-actions` and the git host is GitHub;
3193
+ # both were established above, so nothing guards the call.
3194
+ # `headSha` is the field that makes this answerable at all; `runs` omits
3195
+ # it, which is why that op cannot be reused here.
3196
+ #
3197
+ # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
3198
+ # newest-first, and a sha can carry several (a rerun, or several
3199
+ # workflows). The newest is the live answer; older ones for the same sha
3200
+ # are superseded by the same argument that superseded runs for older shas.
3201
+ # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
3202
+ # and `sha` in the output says WHICH, because a caller that could not tell
3203
+ # the two apart would be back to the branch-scoped guessing this op exists
3204
+ # to end.
3205
+ #
3206
+ # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
3207
+ # the asked-for sha and stopping makes the most important case invisible:
3208
+ # a run IN FLIGHT for a commit the branch has already moved past reports
3209
+ # identically to no run at all, so a caller cannot distinguish *CI has not
3210
+ # started yet* from *CI is busy answering about the past*. The second is
3211
+ # the state that had two merge waiters reporting on superseded runs on
3212
+ # 2026-08-30, and it is exactly what a caller needs to see.
3213
+ #
3214
+ # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
3215
+ # the sha that run is for; whether that sha being different from the one
3216
+ # asked about means "superseded" is the caller's rule. This collects.
3217
+ gh run list --branch "$branch" --limit "$limit" \
3218
+ --json headSha,conclusion,status,startedAt,url 2>/dev/null \
3219
+ | jq -c --arg sha "$sha" \
3220
+ '(map(select(.headSha == $sha)) | .[0]) // .[0]
3221
+ | select(. != null)
3222
+ | {sha:.headSha, status:.status,
3223
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
3224
+ url:.url, startedAt:.startedAt}' 2>/dev/null || true
2642
3225
  ;;
2643
3226
 
2644
3227
  issue-list)
@@ -2674,7 +3257,29 @@ case "$op" in
2674
3257
  # narrower inbox. ORDER BY created DESC so the newest ticket is first, the
2675
3258
  # same order `createdAt` gives the GitHub arm.
2676
3259
  jira_require_config
2677
- jql="${PLOT_JIRA_JQL:-assignee = currentUser() AND resolution = EMPTY ORDER BY created DESC}"
3260
+ # `Ticket prefixes` SCOPES THE DEFAULT to this repository's projects. The
3261
+ # default alone scopes by person and by state, so on a shared instance it
3262
+ # is instance-wide: it returned another customer's ticket to a reporter's
3263
+ # board (see `tracker_projects` for the measurement and the naming).
3264
+ #
3265
+ # THE CLAUSE IS SPLICED, NOT APPENDED, and the split below is why: JQL
3266
+ # requires ORDER BY to close the query, so a clause added to the end of
3267
+ # the default string makes a query Jira rejects.
3268
+ #
3269
+ # AN UNDECLARED KEY LEAVES THE QUERY BYTE-IDENTICAL. `$scope` is empty,
3270
+ # the two halves rejoin exactly as they were written, and an upgrade
3271
+ # changes nothing for a repository that never set the key.
3272
+ scope=""
3273
+ if projects="$(tracker_projects)" && [ -n "$projects" ]; then
3274
+ # `IN (A, B)`, never a trailing comma: paste joins N-1 separators over N
3275
+ # elements, so a one-element list — what adoption's seed writes — yields
3276
+ # `IN (A)` rather than `IN (A,)`.
3277
+ scope=" AND project IN ($(printf '%s' "$projects" | paste -sd, - | sed 's/,/, /g'))"
3278
+ fi
3279
+ # PLOT_JIRA_JQL STILL WINS OVER BOTH. Teams worked around this bug with
3280
+ # their own JQL; an override that stopped overriding would break exactly
3281
+ # the people who noticed the problem first.
3282
+ jql="${PLOT_JIRA_JQL:-assignee = currentUser() AND resolution = EMPTY${scope} ORDER BY created DESC}"
2678
3283
  # maxResults bounds ONE page. The inbox is small by construction (a
2679
3284
  # person's open tickets), so no nextPageToken loop is needed; the caller's
2680
3285
  # --limit caps it, else Jira's default page. v2 `search/jql` takes the same
@@ -2686,7 +3291,7 @@ case "$op" in
2686
3291
  raw="$(jira_curl "/rest/api/2/search/jql" \
2687
3292
  -G \
2688
3293
  --data-urlencode "jql=$jql" \
2689
- --data-urlencode "fields=summary,created" \
3294
+ --data-urlencode "fields=summary,created,status" \
2690
3295
  --data-urlencode "maxResults=$max")"; curl_rc=$?
2691
3296
  body="$(jira_check "$raw" "$curl_rc")" || exit $?
2692
3297
  # `number` is the Jira KEY (PROJ-123), a string — #447 taught the parser to
@@ -2694,21 +3299,39 @@ case "$op" in
2694
3299
  # search payload carries no browse URL, and the base is ours to know
2695
3300
  # (Principle 3: this script is the one place that knows a host URL's shape).
2696
3301
  base="$(tracker_base_url)"
3302
+ # `status` is the workflow's own word (*Internal Approving*) and may be
3303
+ # localised; `statusCategory` is Jira's stable three-value vocabulary
3304
+ # (To Do / In Progress / Done). BOTH are carried because neither
3305
+ # substitutes for the other: the name is what a person reads, the
3306
+ # category is what a board groups on. A board grouping on the name
3307
+ # fragments across projects that spell the same stage differently.
2697
3308
  printf '%s' "$body" | jq -c --arg base "$base" '.issues[]? | {
2698
3309
  number: .key,
2699
3310
  title: (.fields.summary // ""),
2700
3311
  url: ($base + "/browse/" + .key),
2701
- createdAt: (.fields.created // "")
3312
+ createdAt: (.fields.created // ""),
3313
+ status: (.fields.status.name // ""),
3314
+ statusCategory: (.fields.status.statusCategory.name // "")
2702
3315
  }'
2703
3316
  elif [ "$be" = "github" ]; then
2704
3317
  # `gh issue list` — not `gh api /issues`. On GitHub every PR IS an issue,
2705
3318
  # so the REST endpoint returns both, and every open PR would arrive here
2706
3319
  # as a signal nobody had planned. The `gh` subcommand filters PRs out;
2707
3320
  # this note exists because that trap is invisible while it works.
2708
- if out="$(gh issue list --state open ${limit_args[@]+"${limit_args[@]}"} \
3321
+ # `status` is DERIVED from the state this call asks for, never written
3322
+ # twice. GitHub has no workflow states: an issue is open or closed, and
3323
+ # this op asks for open ones only. So the state passed to `gh` is the
3324
+ # answer, and `$gh_state` is read by both the flag and the projection —
3325
+ # a second literal would be a copy that a widened filter silently
3326
+ # outdates. `To Do` is the category because an open GitHub issue is
3327
+ # unstarted by the only vocabulary GitHub has.
3328
+ gh_state="open"
3329
+ if out="$(gh issue list --state "$gh_state" ${limit_args[@]+"${limit_args[@]}"} \
2709
3330
  --json number,title,url,createdAt 2>/tmp/plot-host-err.$$)"; then
2710
3331
  rm -f "/tmp/plot-host-err.$$"
2711
- jq -c '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // "")}' <<<"$out"
3332
+ jq -c --arg status "$gh_state" \
3333
+ '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // ""),
3334
+ status:$status,statusCategory:(if $status == "open" then "To Do" else "Done" end)}' <<<"$out"
2712
3335
  else
2713
3336
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2714
3337
  # NO empty-list fallback. `host_miss_or_fail` exists for a lookup whose
@@ -2748,15 +3371,37 @@ case "$op" in
2748
3371
  [[ "$line" =~ ^#0*([0-9]+)[[:space:]]+(.*)$ ]] || continue
2749
3372
  num="${BASH_REMATCH[1]}"
2750
3373
  rest="${BASH_REMATCH[2]}"
2751
- # Strip the leading state badge word (NEW/OPEN/RESOLVED/…) and its
2752
- # padding; the title starts after it.
2753
- rest="$(sed -E 's/^(NEW|OPEN|ON HOLD|INVALID|RESOLVED|DUPLICATE|WONTFIX|CLOSED)[[:space:]]+//' <<<"$rest")"
3374
+ # Lift the leading state badge word (NEW/OPEN/RESOLVED/…), then strip
3375
+ # it and its padding; the title starts after it. The badge was parsed
3376
+ # here before and THROWN AWAY to recover the title — it is captured
3377
+ # now rather than fetched, so this costs no extra call. ONE
3378
+ # vocabulary, matched once: a second copy in a mapping table is a list
3379
+ # that drifts from the one that strips.
3380
+ state=""
3381
+ if [[ "$rest" =~ ^(NEW|OPEN|ON\ HOLD|INVALID|RESOLVED|DUPLICATE|WONTFIX|CLOSED)[[:space:]]+(.*)$ ]]; then
3382
+ state="${BASH_REMATCH[1]}"
3383
+ rest="${BASH_REMATCH[2]}"
3384
+ fi
3385
+ # Map the badge onto the three-value category the board groups on.
3386
+ # NEW/OPEN are unstarted; RESOLVED/CLOSED are finished. The rest —
3387
+ # ON HOLD, INVALID, DUPLICATE, WONTFIX — get "" DELIBERATELY: they are
3388
+ # terminal-without-being-done or started-without-progressing, and
3389
+ # neither is a Jira category. Empty says *this vocabulary has no word
3390
+ # for it*, which a board can render; inventing `Done` for WONTFIX
3391
+ # would file abandoned work beside finished work.
3392
+ case "$state" in
3393
+ NEW|OPEN) category="To Do" ;;
3394
+ RESOLVED|CLOSED) category="Done" ;;
3395
+ *) category="" ;;
3396
+ esac
2754
3397
  # Drop the trailing reporter: bb prints three spaces then `by <name>`.
2755
3398
  title="$(sed -E 's/[[:space:]]{2,}by [^[:space:]].*$//' <<<"$rest")"
2756
3399
  # `url` is "" — bb issue list prints none; a consumer renders the number
2757
3400
  # as plain text, the rule the header states.
2758
3401
  jq -cn --argjson number "$num" --arg title "$title" \
2759
- '{number:$number,title:$title,url:"",createdAt:""}'
3402
+ --arg status "$state" --arg category "$category" \
3403
+ '{number:$number,title:$title,url:"",createdAt:"",
3404
+ status:$status,statusCategory:$category}'
2760
3405
  count=$((count + 1))
2761
3406
  # Honour the caller's --limit HERE: bb has no --limit, so a bound the
2762
3407
  # caller asked for is enforced by the adapter after parsing (Done-when 7).
@@ -3102,14 +3747,14 @@ case "$op" in
3102
3747
  ci-limit)
3103
3748
  # The CI connector's limit, which is a THIRD axis and does not follow the
3104
3749
  # git host. This repo runs GitHub Actions on a GitHub remote; `ekzweb` runs
3105
- # Jenkins against Bitbucket. `ci_backend()` already resolves it separately.
3750
+ # Jenkins against Bitbucket. `ci_scheme()` already resolves it separately.
3106
3751
  #
3107
3752
  # JENKINS IS THE `predicted` CASE THE DESIGN NAMES. A Jenkins instance
3108
3753
  # reports no rate limit — there is no header and no endpoint to ask — so the
3109
3754
  # ceiling is this adapter's estimate of what a shared controller tolerates,
3110
3755
  # tagged for what it is. It is NOT unlimited: a Jenkins that is hammered
3111
3756
  # refuses, and the refusal is what corrects the estimate.
3112
- _ci="$(ci_backend)"
3757
+ _ci="$(ci_scheme)"
3113
3758
  case "$_ci" in
3114
3759
  jenkins)
3115
3760
  echo '{"connector":"jenkins","bucket":"","limit":60,"remaining":null,"reset":null,"basis":"predicted"}'
@@ -3119,9 +3764,15 @@ case "$op" in
3119
3764
  # answer, not a limit of zero.
3120
3765
  ;;
3121
3766
  *)
3122
- # `ci_backend()` validates nothing, and neither does this — the list is
3767
+ # `ci_scheme()` validates nothing, and neither does this — the list is
3123
3768
  # open, and GitLab and Trello are named as next. A connector nobody has
3124
3769
  # written an estimate for answers `unknown`, which is the honest word.
3770
+ #
3771
+ # IT REPORTS THE SCHEME, never the whole `CI:` value. `build-shell.ts`
3772
+ # filters readings by `reading.connector === shell.system`, and the
3773
+ # system is a bare word — so a prose connector here is discarded before
3774
+ # any caller sees it, and the port defines that empty answer as *a
3775
+ # connector that meters nothing*.
3125
3776
  echo "{\"connector\":\"$_ci\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
3126
3777
  ;;
3127
3778
  esac