@plot-pm/board 0.16.1 → 0.16.3

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
@@ -71,7 +71,54 @@
71
71
  # pr-merge <number> [--squash] [--delete-branch]
72
72
  # pr-ready <number> take a PR out of draft
73
73
  # merge the PR
74
- # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
74
+ # pr-list [--state open|merged|closed|all] [--limit N] [--rich|--rich-open]
75
+ # --rich-open asks the rich fields of the OPEN
76
+ # pull requests only, and emits every row in the
77
+ # --rich shape. A MERGED or CLOSED row carries
78
+ # checks:"unknown", mergeable:"unknown",
79
+ # review:"" and failing_checks:[] — the ABSENT
80
+ # values the Bitbucket arm already emits, which
81
+ # mean NOT ASKED and never "no checks".
82
+ # WHY: the four rich fields are verdicts about a
83
+ # PR's HEAD, and a terminal head is finished —
84
+ # no check will run against it and no review will
85
+ # change. Measured 2026-10-01 on this
86
+ # repository, `--rich --state all --limit 1000`
87
+ # took 43.0 s against 7.3 s without the rich
88
+ # fields, and all 1000 rows were terminal, so
89
+ # 36 s bought answers nobody can use.
90
+ # Re-measured 2026-10-02 with 3 PRs open:
91
+ # `--rich --state open` 2.6-3.3 s, `--state all`
92
+ # 8.6-10.5 s, the split read 12.2-13.5 s.
93
+ # ON GITHUB IT IS TWO CALLS and the terminal
94
+ # rows come from the plain one; a PR open in both
95
+ # payloads is emitted once, from the rich call.
96
+ # A FAILURE IN EITHER CALL PRINTS NO ROWS AND
97
+ # EXITS NON-ZERO — a half answer reported as
98
+ # whole is what would let a caller fold a store
99
+ # that lost every open PR.
100
+ # ON BITBUCKET IT ANSWERS EXACTLY AS --rich,
101
+ # because that arm asks the host no verdict: its
102
+ # rows are already `unknown` whatever the state,
103
+ # so there is no second call to save.
104
+ # THE CALLER STILL ASKS ONCE. One `pr-list`
105
+ # invocation is one host question however many
106
+ # calls this makes, so a caller's per-refresh
107
+ # arithmetic is unchanged.
108
+ # `--limit` BOUNDS EACH CALL, NOT THE UNION, so
109
+ # the row count can exceed it by up to the number
110
+ # of open pull requests. Measured 2026-10-02 with
111
+ # 5 open: `--limit 5` answered 6 rows, where
112
+ # `--rich` answered 5 — the open call returned an
113
+ # open PR that fell outside the plain call's
114
+ # newest-5 page. It is NOT bounded per call and
115
+ # then trimmed, because trimming would drop
116
+ # either a verdict the rich call just bought or a
117
+ # terminal row the store needs, and the caller
118
+ # cannot say which. Harmless where `--limit`
119
+ # exceeds the open count, which is every caller
120
+ # today: the board asks 1000 against 5 open, and
121
+ # no other caller passes this flag.
75
122
  # [--since <iso>] narrows the listing to pull
76
123
  # requests the host has seen change since that
77
124
  # stamp. Measured 2026-09-21 on this repository:
@@ -86,16 +133,19 @@
86
133
  # two seconds fast excludes the PRs updated in
87
134
  # that gap from every later window — forever,
88
135
  # because the window never reopens.
89
- # GITHUB NARROWS AND BITBUCKET'S BULK LISTING
90
- # CANNOT. `gh pr list` takes `--search`; `bb pr
91
- # list` has no query flag at all (verified
92
- # against bb 1.9.0: `unknown flag: --query`), so
93
- # only the per-branch sweep's REST `q=` can
94
- # carry it. The bulk Bitbucket listing SAYS it
95
- # could not narrow and answers in full, because
96
- # a full answer reported as a delta is what
97
- # would let a caller advance a watermark over a
98
- # window it never applied.
136
+ # BOTH HOSTS NARROW, BY DIFFERENT ROUTES.
137
+ # `gh pr list` takes `--search`. `bb pr list`
138
+ # has no query flag (verified against bb 1.9.0:
139
+ # `unknown flag: --query`), so a windowed
140
+ # Bitbucket listing goes through
141
+ # `bb_window_listing`, which asks REST with
142
+ # `q=state="..." AND updated_on>="..."` and
143
+ # walks every page. It refuses a window whose
144
+ # rows fall short of the server's `size` rather
145
+ # than printing it as whole: a full answer
146
+ # reported as a delta is what would let a caller
147
+ # advance a watermark over a window it never
148
+ # applied.
99
149
  # [--repo <owner/repo>] pins the list to ONE
100
150
  # repository, exactly as pr-state and pr-merged
101
151
  # do. A checkout with remotes on two hosts lets
@@ -247,7 +297,9 @@
247
297
  # list` (no --json), pinned to bb 0.6.0. EXIT 4
248
298
  # narrows rather than disappears: it is the
249
299
  # tracker-DISABLED case (bb answers 404/410),
250
- # which stays *this host cannot answer* where an
300
+ # and a bb whose `--help` lists no `issue`
301
+ # command (Quatico bb), both of
302
+ # which stay *this host cannot answer* where an
251
303
  # empty list would say *there are none*. A call
252
304
  # that failed on an enabled tracker, or any error
253
305
  # wording this adapter does not recognise, exits
@@ -281,7 +333,8 @@
281
333
  # Same three outcomes as issue-list, same codes:
282
334
  # BITBUCKET NOW ANSWERS via `bb issue view`
283
335
  # (pinned to 0.6.0); `url` comes from the view's
284
- # footer. EXIT 4 is the tracker-DISABLED case,
336
+ # footer. EXIT 4 is the tracker-DISABLED case
337
+ # or a bb with no `issue` command,
285
338
  # EXIT 3 a lookup that failed or an unrecognised
286
339
  # error. An issue that does not exist is a
287
340
  # FAILURE here, not an empty body: the caller
@@ -468,11 +521,22 @@ die6() { echo "plot-host: $*" >&2; exit 6; }
468
521
  # secondary message contains the phrase *"rate limit"* too — *"You have
469
522
  # exceeded a secondary rate limit"* — so a quota test applied first claims
470
523
  # every secondary refusal and the distinction is lost at the point it is made.
471
- host_failure_kind() { # $1=stderr text → throttled|secondary|failed
524
+ #
525
+ # FOUR ANSWERS, NOT THREE — #1087: a GraphQL 504 matched neither limit pattern,
526
+ # so it came back `failed`, and `failed` is the one kind a command fixes —
527
+ # `gh auth login`, wrong advice for a server that took too long to answer.
528
+ # `timeout` is anchored on the HTTP status word (`HTTP 50[234]`), never a bare
529
+ # `\b504\b`, which would match a run id or a line count just as well. It runs
530
+ # AFTER both limit arms: a rate-limit message that also carries a 5xx status
531
+ # must stay `throttled`, because patience until the reset is the right advice
532
+ # for it, and `timeout` would counsel exactly the wrong patience — none.
533
+ host_failure_kind() { # $1=stderr text → secondary|throttled|timeout|failed
472
534
  if LC_ALL=C grep -qiE 'secondary rate|exceeded a secondary|abuse detection|abuse-detection|too many requests|\b429\b' <<<"$1"; then
473
535
  echo secondary
474
536
  elif LC_ALL=C grep -qiE 'rate limit|ratelimit' <<<"$1"; then
475
537
  echo throttled
538
+ elif LC_ALL=C grep -qiE "HTTP 50[234]|couldn.t respond to your request in time" <<<"$1"; then
539
+ echo timeout
476
540
  else
477
541
  echo failed
478
542
  fi
@@ -552,6 +616,16 @@ pr_list_failed() { # $1=stderr text
552
616
  echo " or run against an account with quota left. No login will help." >&2
553
617
  exit 5
554
618
  ;;
619
+ timeout)
620
+ # NO REPAIR NAMED, FOR THE SAME REASON SECONDARY NAMES NONE — #1087. A
621
+ # 504 means the server took too long, not that the credential is bad, so
622
+ # `host_repair`'s `gh auth login` would send a reader to fix a login
623
+ # that was never broken. The decision is to ask again, not to log in.
624
+ echo "plot-host: pr-list: host timed out — ${err:-the host took too long and said nothing}" >&2
625
+ echo " Nothing is wrong with the login: the server took too long to answer." >&2
626
+ echo " The next refresh asks again." >&2
627
+ exit 3
628
+ ;;
555
629
  esac
556
630
  # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
557
631
  # the repair for the first is a login this connector can name — see
@@ -1016,7 +1090,7 @@ bb_state_listing() { # global bb args… --state <s> --json → one JSON array
1016
1090
  esac
1017
1091
  done
1018
1092
  [ -n "$_st" ] || die "bb_state_listing: no --state"
1019
- _out="$(bb ${_args[@]+"${_args[@]}"} api "/repositories/{ws}/{repo}/pullrequests?state=$(bb_query_state "$_st")&pagelen=50")" || return $?
1093
+ _out="$(bb ${_args[@]+"${_args[@]}"} api "/repositories/{ws}/{repo}/pullrequests?state=$(bb_query_state "$_st")&pagelen=$BB_LIST_PAGELEN")" || return $?
1020
1094
  printf '%s' "$_out" | jq -c '.values // []'
1021
1095
  }
1022
1096
 
@@ -2033,17 +2107,41 @@ bb_issue_exit_code() {
2033
2107
  # fails LOUDLY rather than silently mis-reading a column that may have moved.
2034
2108
  # `PLOT_BB_SKIP_VERSION_CHECK` exists for the test harness, whose stub bb has no
2035
2109
  # meaningful version — the parse is exercised against captured fixture text.
2110
+ #
2111
+ # A VERSION MISMATCH IS NOT ALWAYS A MOVED FORMAT. Two products share the name
2112
+ # `bb` (see the capability check below), and Quatico `bb` — 1.9.0 measured
2113
+ # 2026-10-01 — has no `issue` command at all: `bb --help` lists `pr …`,
2114
+ # `source …` and `api`. Telling it to update would suggest a fix that does not
2115
+ # exist, so a mismatch first asks `bb --help` what the CLI offers. A help text
2116
+ # that lists a `pr` command and no `issue` command is a bb that cannot be asked
2117
+ # about issues, which is exit 4 — the same answer as a disabled tracker. Any
2118
+ # other help text keeps the refusal below, exit 3. Exits 4 or 3; 0 passes.
2036
2119
  bb_assert_issue_version() {
2037
2120
  [ -n "${PLOT_BB_SKIP_VERSION_CHECK:-}" ] && return 0
2038
2121
  local v
2039
2122
  v="$(bb --version 2>/dev/null | bb_strip_ansi | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
2040
2123
  if [ "$v" != "$BB_ISSUE_VERSION" ]; then
2124
+ if bb_lacks_issue_command; then
2125
+ echo "plot-host: bb ${v:-unknown} lists no issue command — Quatico bb has none, unlike craftamap/bb — so this repository's issues cannot be read through it; declare the issue tracker with the \`Tracker\` config key" >&2
2126
+ return 4
2127
+ fi
2041
2128
  echo "plot-host: bb issue parse targets $BB_ISSUE_VERSION but found '${v:-unknown}' — refusing to mis-read a format that may have moved" >&2
2042
2129
  return 3
2043
2130
  fi
2044
2131
  return 0
2045
2132
  }
2046
2133
 
2134
+ # Whether `bb --help` lists commands and `issue` is not one of them. A help
2135
+ # that lists no `pr` command either is not a command listing this can read, so
2136
+ # it answers *not proven* (1) and the caller keeps its exit-3 refusal: guessing
2137
+ # 4 from unreadable help would turn a broken bb into *no issue tracker*.
2138
+ bb_lacks_issue_command() {
2139
+ local help
2140
+ help="$(bb --help 2>&1 | bb_strip_ansi)" || return 1
2141
+ grep -qE '^[[:space:]]+pr([[:space:]]|$)' <<<"$help" || return 1
2142
+ ! grep -qE '^[[:space:]]+issues?([[:space:]]|$)' <<<"$help"
2143
+ }
2144
+
2047
2145
  # --- bb capability check (--json support) ------------------------------------
2048
2146
  #
2049
2147
  # TWO TOOLS SHARE THE NAME `bb`. craftamap/bb is a Go binary that does NOT
@@ -2254,6 +2352,47 @@ tracker_scheme() {
2254
2352
  tracker_raw | awk '{print tolower($1)}'
2255
2353
  }
2256
2354
 
2355
+ # WHO ANSWERS THE OPEN-ISSUE LIST — the domain's answer, not this script's.
2356
+ #
2357
+ # `issue-list` and `issue-view` tested `tracker_scheme = jira` and sent every
2358
+ # other scheme to the git host. That test was a DRIFTED SECOND COPY of the rule:
2359
+ # measured 2026-10-01, `PLOT_HOST=github PLOT_TRACKER=linear issue-list` exited 0
2360
+ # having called `gh issue list`, so a repository tracking in Linear was shown
2361
+ # GitHub's issues under Linear's name — a list not wrong about any single row
2362
+ # and wrong about all of them.
2363
+ #
2364
+ # So the rule is ASKED rather than re-implemented. `issueSource` has eight unit
2365
+ # tests and the board already asks it (`fleet.ts:2369-2380`); a lister list in
2366
+ # shell would be the same copy one directory over, free to drift again the first
2367
+ # time a connector is added.
2368
+ #
2369
+ # THE ONE NODE HOP THIS SCRIPT MAKES, and the cost rule permits it: `plot-host.sh`
2370
+ # runs once per operator command or once per board PR refresh, never once per
2371
+ # agent per pass, and a shipped bundle answers in 39 ms
2372
+ # (`docs/shell-and-domain.md`).
2373
+ #
2374
+ # Prints the entry's line verbatim — `tracker\t<scheme>`, `git-host`, or
2375
+ # `nobody\t<reason>` — and exits 0. Exits 1, naming the entry, when the rule
2376
+ # CANNOT BE ASKED: no node, no bundle, a non-zero exit, or a word outside the
2377
+ # three. THAT CASE NEVER FALLS THROUGH TO THE GIT HOST — the fall-through is
2378
+ # the defect, and `an-outage-is-not-an-answer` is what a list saying *none*
2379
+ # because it could not ask reproduces.
2380
+ issue_source() {
2381
+ local out rc
2382
+ out="$(tracker_raw | node "$here/board/plot-issue-source.mjs" "$be" 2>/dev/null)"; rc=$?
2383
+ if [ "$rc" -ne 0 ]; then
2384
+ echo "plot-host: cannot ask board/plot-issue-source.mjs who lists this repository's issues (exit $rc); the question failed and the git host is not asked in its place" >&2
2385
+ return 1
2386
+ fi
2387
+ case "${out%%$'\t'*}" in
2388
+ tracker|git-host|nobody) printf '%s\n' "$out" ;;
2389
+ *)
2390
+ echo "plot-host: board/plot-issue-source.mjs answered '${out}', which is not one of tracker/git-host/nobody; the question failed and the git host is not asked in its place" >&2
2391
+ return 1
2392
+ ;;
2393
+ esac
2394
+ }
2395
+
2257
2396
  tracker_base_url() {
2258
2397
  # The base URL is the SECOND token; a bare `jira` with no URL yields "".
2259
2398
  # PLOT_JIRA_BASE_URL overrides, for a caller that has the URL separately.
@@ -2516,27 +2655,36 @@ jira_check() {
2516
2655
  # `bb` returned 50 merged PRs (ids 836→787) against a repo numbering to 836, so
2517
2656
  # ~780 older merged PRs were invisible to the join.
2518
2657
  #
2519
- # THE DETECTOR IS AGAINST THE REQUESTED LIMIT, NEVER THE CONSTANT 50. A future
2520
- # `bb` page size of 100 must not make a truncated 100-row list report complete —
2521
- # this plan's own defect restored. So the rule names no page size:
2658
+ # THE RULE IS `rules/listing-page.ts`'s `pagePossiblyTruncated`, and this is
2659
+ # its shell copy. `pr-list` runs on every board refresh, so the shell keeps the
2660
+ # rule rather than asking a bundle (docs/shell-and-domain.md), and
2661
+ # `packages/domain/corpus/listing-page.corpus.test.ts` holds the pair:
2522
2662
  #
2523
2663
  # github (HONOURS --limit) : a state is possibly truncated when it returned
2524
- # AT LEAST the requested limit — the host may have
2525
- # had more that the limit hid. Fewer rows than the
2526
- # limit PROVES completeness.
2527
- # bitbucket (IGNORES --limit): `bb pr list` has no --limit and reports neither
2528
- # a total nor a cursor, so it can NEVER prove
2529
- # completeness for a --limit call. Any non-empty
2530
- # page is therefore possibly truncated. An empty
2531
- # page had nothing to truncate.
2532
- #
2533
- # THE PREMISE ABOVE IS ABOUT `bb pr list`, AND IT WAS ONCE WRITTEN ABOUT
2534
- # BITBUCKET. It said the host "cannot report a total or a cursor" — true of the
2535
- # CLI's listing and false of the REST endpoint behind it, which carries both a
2536
- # `size` and a `next`. That mattered the moment a path existed that could ask:
2537
- # the per-branch sweep (#333) proves completeness exactly, per branch, and this
2538
- # detector is deliberately not asked about it (`pr_list_states`). The rule below
2539
- # is unchanged and still governs every listing call.
2664
+ # AT LEAST the requested limit. Fewer rows than
2665
+ # the limit PROVES completeness.
2666
+ # bitbucket (ONE FIXED PAGE): the listing ignores --limit and returns one page
2667
+ # per state. A page SHORTER than the page length
2668
+ # is the last page, so it is complete. A page AT
2669
+ # the length stays possibly truncated.
2670
+ #
2671
+ # THE PAGE LENGTH IS A READING, NEVER A CONSTANT THE RULE NAMES. The listing
2672
+ # asks `pagelen=$BB_LIST_PAGELEN` through `bb api`, and the length holds only
2673
+ # where that `bb` passes the query through unchanged, so it is read per version
2674
+ # from `BB_LIST_PAGE_VERSIONS` — pinned like `BB_ISSUE_VERSION`. A version
2675
+ # nobody measured has no length, and every non-empty page under it stays
2676
+ # possibly truncated: the rule never calls a page complete it cannot prove. A
2677
+ # future length of 100 is a new table entry, never a page of 50 read as short.
2678
+ #
2679
+ # Measured on `quatico/quaweb-website`: bb 1.9.0 returned 50 merged rows in one
2680
+ # request on 2026-09-30, and on 2026-10-01 answered `pagelen: 50` with 50 rows,
2681
+ # a `next` and `size: 895`. Before
2682
+ # 2026-10-01 every non-empty Bitbucket page was reported, so 20 open PRs read as
2683
+ # truncated on every refresh (#1137).
2684
+ #
2685
+ # THE PER-BRANCH SWEEP AND THE WINDOWED LISTING MAKE NO PAGE CLAIM. The sweep
2686
+ # proves completeness per branch and the window refuses a short read, so this
2687
+ # detector is not asked about either (`pr_list_states`).
2540
2688
  #
2541
2689
  # No --limit was requested → the caller accepted the host's default page and is
2542
2690
  # owed no report, so no existing no-limit caller's behaviour changes.
@@ -2555,21 +2703,82 @@ jira_check() {
2555
2703
  # future diff that teaches the scan to fall back, without moving the failure
2556
2704
  # into a minutes-long pulse. See the plan's Done-when item 3.
2557
2705
  #
2706
+ # The page length Bitbucket's plain listing asks for. `bb_state_listing` sends
2707
+ # it, and `pr_list_report_truncation` reads it as the page length only for a
2708
+ # `bb` in BB_LIST_PAGE_VERSIONS.
2709
+ BB_LIST_PAGELEN=50
2710
+
2711
+ # The `bb` versions measured to pass `pagelen` through `bb api` unchanged,
2712
+ # space-separated. `adapters/host/listing-paging.ts` holds the same table.
2713
+ BB_LIST_PAGE_VERSIONS="1.9.0"
2714
+
2715
+ # The page length the answering `bb` lists by, or nothing where its version is
2716
+ # not in BB_LIST_PAGE_VERSIONS. Reads the version `bb_require_json` recorded,
2717
+ # so it costs no `bb` call; a skipped capability check records no version.
2718
+ bb_list_page_length() {
2719
+ local v="${BB_CAP_IDENTITY#*/}"
2720
+ case " $BB_LIST_PAGE_VERSIONS " in
2721
+ *" $v "*) printf '%s\n' "$BB_LIST_PAGELEN" ;;
2722
+ esac
2723
+ }
2724
+
2725
+ # A PAGE THAT IS PROVABLY WHOLE SAYS SO, and that sentence is the licence a
2726
+ # joining caller needs. This reported only truncation until 2026-10-02: a
2727
+ # complete page was SILENT, so a caller could not tell "this page holds every
2728
+ # PR" from "this adapter has no opinion", and the only reading left to it was
2729
+ # the page's own row count against its own limit. That is a coincidence rather
2730
+ # than evidence — measured on this repository, `--state all --limit 1000`
2731
+ # returned exactly 1000 rows of 1064 PRs, so the count-based test failed, the
2732
+ # scan's `.list-complete` was withheld, and every branch the join did not name
2733
+ # fell through to one `pr-state` call each: 26 calls at 3.8 s, 54-61% of the
2734
+ # scan's wall time in slice 1's five runs (#1017).
2735
+ #
2736
+ # THE CLAIM IS THE ADAPTER'S BECAUSE THE PAGING SEMANTICS ARE. Whether a short
2737
+ # page proves anything depends on how the host pages, which is exactly what
2738
+ # this script knows and a caller does not. `pr_sweep_report` already states its
2739
+ # own stronger claim in this shape and `plot-fleet-scan.sh` already reads that
2740
+ # sentence; this is the same contract for the second path, so a listing is no
2741
+ # longer the one answer a caller has to guess at.
2742
+ #
2743
+ # BOTH SENTENCES OR NEITHER. The complete and truncated reports are the two
2744
+ # outcomes of one decision, so they are emitted from one place: a reader can
2745
+ # never see both for one state, and silence now means only that no claim was
2746
+ # owed — no `--limit`, or an empty page.
2747
+ #
2558
2748
  # $1 backend $2 requested limit (may be "") $3 state word $4 row count
2559
2749
  pr_list_report_truncation() {
2560
- local be="$1" limit="$2" state="$3" count="$4"
2750
+ local be="$1" limit="$2" state="$3" count="$4" len=""
2561
2751
  [ -n "$limit" ] || return 0 # no --limit → no completeness claim owed
2562
2752
  [ "$count" -gt 0 ] 2>/dev/null || return 0 # an empty page had nothing to hide
2563
2753
  if [ "$be" = "github" ]; then
2564
2754
  # github honours the limit: complete unless the page came back AT the limit.
2565
- [ "$count" -ge "$limit" ] 2>/dev/null || return 0
2755
+ # Measured 2026-10-02 on this repository (1064 PRs): `--limit 1100`
2756
+ # answered 1065 rows and `--limit 2000` the same 1065, so a page short of
2757
+ # its limit is the whole list and asking generously costs the rows that
2758
+ # exist rather than the rows requested.
2759
+ if [ "$count" -lt "$limit" ] 2>/dev/null; then pr_list_report_complete "$be" "$limit" "$state" "$count"; return 0; fi
2760
+ else
2761
+ # A fixed page: complete below a measured page length, unprovable otherwise.
2762
+ [ "$be" = "bitbucket" ] && len="$(bb_list_page_length)"
2763
+ if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then pr_list_report_complete "$be" "$limit" "$state" "$count"; return 0; fi
2566
2764
  fi
2567
- # bitbucket: any non-empty page for a --limit call is unprovable, so it falls
2568
- # through to the report. Named per state so a future caller can resolve exactly
2569
- # the states that were capped, not a whole-call flag that over-reports.
2765
+ # Named per state so a caller can resolve exactly the states that were
2766
+ # capped, not a whole-call flag that over-reports.
2570
2767
  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
2571
2768
  }
2572
2769
 
2770
+ # THE WORDING IS A CONTRACT between this script and `plot-fleet-scan.sh`, pinned
2771
+ # on both sides exactly as `pr-list sweep complete` is. A page that cannot prove
2772
+ # itself whole never prints it, so a match is licence and a miss is silence —
2773
+ # never a guess.
2774
+ #
2775
+ # PER STATE, like the truncation report beside it, because the scan asks for two
2776
+ # states and joins both: a caller needs to know which pages were whole, not that
2777
+ # some were.
2778
+ pr_list_report_complete() { # $1 backend $2 limit $3 state word $4 row count
2779
+ echo "plot-host: $1 pr-list state=$3 page complete ($4 rows below requested limit $2) — this page holds every pull request the host has in this state" >&2
2780
+ }
2781
+
2573
2782
  # The backends this script has an arm for, which is what "drivable" means here.
2574
2783
  #
2575
2784
  # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
@@ -3599,6 +3808,10 @@ case "$op" in
3599
3808
  pr-list)
3600
3809
  state="open"
3601
3810
  rich=0
3811
+ # Whether the rich fields are asked of the OPEN pull requests only. Set by
3812
+ # `--rich-open`, which also sets `rich`: the flag narrows what is ASKED and
3813
+ # never what is emitted.
3814
+ rich_open=0
3602
3815
  # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
3603
3816
  # `pr-merged` already take. A checkout may carry several remotes on several
3604
3817
  # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
@@ -3623,6 +3836,22 @@ case "$op" in
3623
3836
  --state) state="${2:?}"; shift 2 ;;
3624
3837
  --limit) limit="${2:?}"; shift 2 ;;
3625
3838
  --rich) rich=1; shift ;;
3839
+ # THE RICH FIELDS OF THE OPEN PRs AND THE PLAIN FIELDS OF THE REST.
3840
+ # Measured 2026-10-01 on this repository: `--rich --state all --limit
3841
+ # 1000` took 43.0 s and the same call without the rich fields took 7.3 s,
3842
+ # while all 1000 rows were terminal — so 36 s bought four verdicts about
3843
+ # heads nobody can act on. Re-measured 2026-10-02 with 3 PRs open:
3844
+ # `--rich --state open` is 2.6-3.3 s and `--state all` is 8.6-10.5 s.
3845
+ #
3846
+ # IT IMPLIES `--rich`, so every row carries every rich field and no
3847
+ # consumer has to ask which call produced it. A terminal row's verdicts
3848
+ # arrive at the ABSENT values the Bitbucket arm already emits, which the
3849
+ # store's schema accepts — so no `PR_INDEX_VERSION` bump follows.
3850
+ #
3851
+ # THE SPLIT IS ONLY WORTH MAKING OVER A SET THAT HOLDS TERMINAL ROWS.
3852
+ # With `--state open` asked, both calls would list the same PRs and the
3853
+ # second would be waste, so this collapses to plain `--rich` there.
3854
+ --rich-open) rich=1; rich_open=1; shift ;;
3626
3855
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3627
3856
  # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3628
3857
  # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
@@ -3701,6 +3930,57 @@ case "$op" in
3701
3930
  fi
3702
3931
 
3703
3932
  if [ "$be" = "github" ]; then
3933
+ # THE SPLIT READ, AND IT IS TWO CALLS BECAUSE THE COST IS PER ROW.
3934
+ # `gh pr list` pages internally and takes no page-size flag, so 1000 rich
3935
+ # rows cost 1000 rows' worth of `statusCheckRollup` whatever the page.
3936
+ # Narrowing the SET is the only lever: the rich fields are asked of the
3937
+ # open pull requests, and everything else comes back plain.
3938
+ #
3939
+ # ORDER MATTERS — THE RICH ROWS GO FIRST. A PR that is open appears in
3940
+ # both payloads, and a consumer keyed by number takes the last line it
3941
+ # read; the second call is filtered to the terminal states instead, so no
3942
+ # number is emitted twice and no rich row is overwritten by a plain one.
3943
+ # Filtered rather than ordered, because a caller that sorts or de-dupes
3944
+ # differently must not be able to reach a different answer.
3945
+ #
3946
+ # ASKED ONLY WHERE THE SET CAN HOLD A TERMINAL ROW. With `--state open`
3947
+ # the two calls would list the same PRs, so this collapses to `--rich`.
3948
+ if [ "$rich_open" = 1 ] && [ "$state" != "open" ]; then
3949
+ # THE OPEN HALF, RICH. Asked with the caller's own `--limit` and
3950
+ # `--since`: a window the caller set applies to both halves, or the two
3951
+ # would answer about different populations.
3952
+ #
3953
+ # `|| exit $?` ON BOTH, and the rule `pr_list_call`'s header states is
3954
+ # what makes it load-bearing here too: this runs in a command
3955
+ # substitution, so a `die` inside leaves only that subshell and the
3956
+ # outer script would carry on with an empty payload. A failing open call
3957
+ # and a failing all call must each exit non-zero and print no rows.
3958
+ _rich_open_out="$("$0" pr-list --rich --state open \
3959
+ ${limit:+--limit "$limit"} ${since:+--since "$since"} \
3960
+ ${repo_args[1]+--repo "${repo_args[1]}"} \
3961
+ $(for _b in $branches; do printf -- '--branch %s ' "$_b"; done))" || exit $?
3962
+ # THE REST, PLAIN, AND THE TERMINAL ROWS ARE THE ONES KEPT. `--state
3963
+ # all` is asked rather than merged+closed separately: GitHub answers it
3964
+ # in one call, and two calls would double a cost this exists to halve.
3965
+ _plain_all_out="$("$0" pr-list --state "$state" \
3966
+ ${limit:+--limit "$limit"} ${since:+--since "$since"} \
3967
+ ${repo_args[1]+--repo "${repo_args[1]}"} \
3968
+ $(for _b in $branches; do printf -- '--branch %s ' "$_b"; done))" || exit $?
3969
+ [ -n "$_rich_open_out" ] && printf '%s\n' "$_rich_open_out"
3970
+ # THE ABSENT VALUES ARE THE BITBUCKET ARM'S, not invented here: a
3971
+ # terminal row carries every field a rich row carries, so one shape
3972
+ # reaches every consumer. `checks:"unknown"` means NOT ASKED and never
3973
+ # *no checks* — the board's fold takes the verdict it already holds for
3974
+ # the same number, and a row with none reads as unavailable.
3975
+ #
3976
+ # `draft`, `url` and `updatedAt` are REAL here — the plain GitHub arm
3977
+ # asks for them — so only the four verdicts are absent.
3978
+ [ -n "$_plain_all_out" ] && printf '%s\n' "$_plain_all_out" \
3979
+ | jq -c 'select(.state=="MERGED" or .state=="CLOSED")
3980
+ | . + {checks:"unknown", mergeable:"unknown", review:"",
3981
+ failing_checks:[]}'
3982
+ exit 0
3983
+ fi
3704
3984
  if [ "$rich" = 1 ]; then
3705
3985
  # `checks` has FOUR states, and two of them mean "a person is the
3706
3986
  # blocker" rather than "a machine is busy":
@@ -3840,12 +4120,25 @@ case "$op" in
3840
4120
  }'
3841
4121
  fi
3842
4122
  else
4123
+ # `isDraft`, `url` AND `updatedAt` TRAVEL ON THE PLAIN ARM TOO, and they
4124
+ # are cheap: measured 2026-09-21, `number,updatedAt` over 937 PRs is
4125
+ # 4715 ms against 5417 ms for the base fields, where
4126
+ # `statusCheckRollup` alone is 18 842 ms. They are scalar columns on the
4127
+ # PR node; the rollup is a per-row resolution, and that is the whole
4128
+ # 36 s `--rich-open` removes.
4129
+ #
4130
+ # ASKED HERE BECAUSE `--rich-open`'S TERMINAL ROWS COME THROUGH THIS
4131
+ # ARM, and a terminal row must carry every field a rich row carries —
4132
+ # `updatedAt` most of all, since it is the field a durable store
4133
+ # advances its watermark by. Without it a split full read would leave
4134
+ # the store unable to narrow and the daily full read would never fall
4135
+ # due against anything but a cold window.
3843
4136
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3844
- --json number,title,state,headRefName,author)" || exit $?
4137
+ --json number,title,state,headRefName,isDraft,url,updatedAt,author)" || exit $?
3845
4138
  pr_list_report_truncation github "$limit" "$state" \
3846
4139
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3847
4140
  printf '%s' "$_gh_raw" \
3848
- | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
4141
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,draft:.isDraft,url:.url,updatedAt:(.updatedAt // ""),author:(.author.login // "")}'
3849
4142
  fi
3850
4143
  else
3851
4144
  # `author` IS THE `nickname`, the one handle field a Bitbucket user
@@ -4266,7 +4559,19 @@ case "$op" in
4266
4559
  done
4267
4560
  limit_args=()
4268
4561
  [ -n "$limit" ] && limit_args=(--limit "$limit")
4269
- if [ "$(tracker_scheme)" = "jira" ]; then
4562
+ # WHO LISTS THESE ISSUES IS ASKED ONCE, HERE, BEFORE THE ARMS — after the
4563
+ # argument parsing, so a caller's typo is still reported as a usage error
4564
+ # rather than as a tracker refusal. The arms below then dispatch on the
4565
+ # ANSWER, never on a second reading of the config.
4566
+ source_said="$(issue_source)" || exit 1
4567
+ if [ "${source_said%%$'\t'*}" = "nobody" ]; then
4568
+ # Exit 4, the code this op already documents as *this host cannot be
4569
+ # asked at all* — a configuration a person fixes, not a call to retry.
4570
+ # NOTHING HAS BEEN SPENT: no `gh`, no `bb`, no curl has run yet.
4571
+ echo "plot-host: ${source_said#*$'\t'}" >&2
4572
+ exit 4
4573
+ fi
4574
+ if [ "$source_said" = $'tracker\tjira' ]; then
4270
4575
  # Jira, resolved through the REST API — DISPATCHED ON `Tracker`, never on
4271
4576
  # `backend()`: a Bitbucket repo tracking in Jira is the normal enterprise
4272
4577
  # case, so the git host is irrelevant here (see the Jira helpers up top).
@@ -4444,7 +4749,15 @@ case "$op" in
4444
4749
  # 4 to `unsupported` and anything else to `failed` must not need a second
4445
4750
  # table to read this op.
4446
4751
  num="${1:?issue-view needs an issue number}"; shift
4447
- if [ "$(tracker_scheme)" = "jira" ]; then
4752
+ # Asked once, before the arms, exactly as issue-list does — the same
4753
+ # question with the same three answers and the same exit codes. A consumer
4754
+ # that maps 4 to `unsupported` needs no second table to read this op.
4755
+ source_said="$(issue_source)" || exit 1
4756
+ if [ "${source_said%%$'\t'*}" = "nobody" ]; then
4757
+ echo "plot-host: ${source_said#*$'\t'}" >&2
4758
+ exit 4
4759
+ fi
4760
+ if [ "$source_said" = $'tracker\tjira' ]; then
4448
4761
  # Jira, dispatched on `Tracker` not `backend()` — the same rule issue-list
4449
4762
  # follows. `num` is a Jira KEY (PROJ-123), read off issue-list moments ago.
4450
4763
  #
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "is this monitor's subject still there?"
3
3
  #
4
- # SOURCED, NOT RUN, by `plot-worker-monitor.sh` and `plot-agent-monitor.sh`.
4
+ # SOURCED, NOT RUN, by `plot-agent-monitor.sh` and `plot-build-monitor.sh`.
5
5
  # Both need the same computation and neither renders it the same way, which is
6
6
  # the same shape as `plot-worker-state.sh` and `plot-pr-merged.sh` — and the
7
7
  # same reason. `plot-worker-state.sh` carried five of its six states in
@@ -132,9 +132,19 @@ plot_monitor_subject() {
132
132
  pid=$(cat "$pid_file" 2>/dev/null | tr -d ' \n')
133
133
 
134
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.
135
+ # startup window caught mid-`printf`. Not gone — UNLESS the wrapper has
136
+ # already recorded an exit beside it. `start_worker` removes that record
137
+ # before every start, so in the startup window it is absent. A worker loop
138
+ # that moves to a new desk empties the pid file it leaves
139
+ # (`move_worker_record`), and the wrapper still writes the exit into this
140
+ # desk when the loop ends; without this arm a monitor watching that desk
141
+ # would read `starting` forever.
136
142
  case "$pid" in
137
- '' | *[!0-9]*) printf 'starting'; return 0 ;;
143
+ '' | *[!0-9]*)
144
+ if [ -f "$(dirname "$pid_file")/.plot-worker.exit" ]; then
145
+ printf 'gone'; return 0
146
+ fi
147
+ printf 'starting'; return 0 ;;
138
148
  esac
139
149
 
140
150
  if kill -0 "$pid" 2>/dev/null; then
@@ -192,3 +202,45 @@ plot_monitor_wait() { # $1 = seconds to wait, $2 = pid file
192
202
  done
193
203
  return 0
194
204
  }
205
+
206
+ # ═══════════════════════════════════════════════════════════════════════════
207
+ # WHICH DESK A MONITOR WATCHES THIS PASS, AFTER A HOP MAY HAVE MOVED ITS AGENT
208
+ # ═══════════════════════════════════════════════════════════════════════════
209
+ #
210
+ # `PLOT_WORKTREE` is fixed at launch, but `update_manifest_on_hop` rewrites the
211
+ # manifest's `worktree` field when the loop cuts a new desk — so a monitor that
212
+ # never re-reads it watches the launch desk forever. This is the shell twin of
213
+ # `watchedDesk` in `packages/domain/src/rules/desk-manifest.ts`, duplicated
214
+ # rather than called for the reason `docs/shell-and-domain.md` gives: both
215
+ # monitors re-read this every pass (30 s and 300 s), and a 39 ms bundle hop paid
216
+ # on every pass forever is the cost the shell side exists to avoid.
217
+ # `desk-manifest.corpus.test.ts` holds the pair; a disagreement stops the
218
+ # branch rather than being adjusted away.
219
+ #
220
+ # `$1` = the manifest file this monitor was handed (`PLOT_MANIFEST_FILE`), may
221
+ # be empty or absent.
222
+ # `$2` = the desk this monitor was launched on (`PLOT_WORKTREE`).
223
+ # Prints the desk to watch THIS pass.
224
+ plot_watched_desk() { # $1 = manifest file, $2 = launch desk → prints the watched desk
225
+ local manifest_file="${1:-}" launched="${2:-}" field
226
+ # ABSENT IS NOT FALSE. No manifest file named, or one that is gone, both mean
227
+ # "watch what you were launched on" — never a crash and never "watch
228
+ # nothing". A hand-started monitor with no `PLOT_MANIFEST_FILE` is a supported
229
+ # shape, not an error.
230
+ if [ -n "$manifest_file" ] && [ -f "$manifest_file" ]; then
231
+ # Same grep-and-sed idiom `plot_manifest_for_worktree` uses: the manifest is
232
+ # pretty-printed one field per line, so this avoids parsing JSON in bash.
233
+ field=$(grep -m1 '"worktree":' "$manifest_file" 2>/dev/null | sed 's/.*"worktree": *"\([^"]*\)".*/\1/')
234
+ else
235
+ field=''
236
+ fi
237
+ # Whitespace-only reads as absent too, matching the rule: trim leading and
238
+ # trailing space before testing for emptiness.
239
+ field="${field#"${field%%[![:space:]]*}"}"
240
+ field="${field%"${field##*[![:space:]]}"}"
241
+ if [ -n "$field" ]; then
242
+ printf '%s' "$field"
243
+ else
244
+ printf '%s' "$launched"
245
+ fi
246
+ }