@plot-pm/board 0.16.2 → 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
@@ -471,11 +521,22 @@ die6() { echo "plot-host: $*" >&2; exit 6; }
471
521
  # secondary message contains the phrase *"rate limit"* too — *"You have
472
522
  # exceeded a secondary rate limit"* — so a quota test applied first claims
473
523
  # every secondary refusal and the distinction is lost at the point it is made.
474
- 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
475
534
  if LC_ALL=C grep -qiE 'secondary rate|exceeded a secondary|abuse detection|abuse-detection|too many requests|\b429\b' <<<"$1"; then
476
535
  echo secondary
477
536
  elif LC_ALL=C grep -qiE 'rate limit|ratelimit' <<<"$1"; then
478
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
479
540
  else
480
541
  echo failed
481
542
  fi
@@ -555,6 +616,16 @@ pr_list_failed() { # $1=stderr text
555
616
  echo " or run against an account with quota left. No login will help." >&2
556
617
  exit 5
557
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
+ ;;
558
629
  esac
559
630
  # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
560
631
  # the repair for the first is a login this connector can name — see
@@ -2281,6 +2352,47 @@ tracker_scheme() {
2281
2352
  tracker_raw | awk '{print tolower($1)}'
2282
2353
  }
2283
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
+
2284
2396
  tracker_base_url() {
2285
2397
  # The base URL is the SECOND token; a bare `jira` with no URL yields "".
2286
2398
  # PLOT_JIRA_BASE_URL overrides, for a caller that has the URL separately.
@@ -2610,6 +2722,29 @@ bb_list_page_length() {
2610
2722
  esac
2611
2723
  }
2612
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
+ #
2613
2748
  # $1 backend $2 requested limit (may be "") $3 state word $4 row count
2614
2749
  pr_list_report_truncation() {
2615
2750
  local be="$1" limit="$2" state="$3" count="$4" len=""
@@ -2617,17 +2752,33 @@ pr_list_report_truncation() {
2617
2752
  [ "$count" -gt 0 ] 2>/dev/null || return 0 # an empty page had nothing to hide
2618
2753
  if [ "$be" = "github" ]; then
2619
2754
  # github honours the limit: complete unless the page came back AT the limit.
2620
- [ "$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
2621
2760
  else
2622
2761
  # A fixed page: complete below a measured page length, unprovable otherwise.
2623
2762
  [ "$be" = "bitbucket" ] && len="$(bb_list_page_length)"
2624
- if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then return 0; fi
2763
+ if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then pr_list_report_complete "$be" "$limit" "$state" "$count"; return 0; fi
2625
2764
  fi
2626
2765
  # Named per state so a caller can resolve exactly the states that were
2627
2766
  # capped, not a whole-call flag that over-reports.
2628
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
2629
2768
  }
2630
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
+
2631
2782
  # The backends this script has an arm for, which is what "drivable" means here.
2632
2783
  #
2633
2784
  # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
@@ -3657,6 +3808,10 @@ case "$op" in
3657
3808
  pr-list)
3658
3809
  state="open"
3659
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
3660
3815
  # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
3661
3816
  # `pr-merged` already take. A checkout may carry several remotes on several
3662
3817
  # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
@@ -3681,6 +3836,22 @@ case "$op" in
3681
3836
  --state) state="${2:?}"; shift 2 ;;
3682
3837
  --limit) limit="${2:?}"; shift 2 ;;
3683
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 ;;
3684
3855
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3685
3856
  # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3686
3857
  # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
@@ -3759,6 +3930,57 @@ case "$op" in
3759
3930
  fi
3760
3931
 
3761
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
3762
3984
  if [ "$rich" = 1 ]; then
3763
3985
  # `checks` has FOUR states, and two of them mean "a person is the
3764
3986
  # blocker" rather than "a machine is busy":
@@ -3898,12 +4120,25 @@ case "$op" in
3898
4120
  }'
3899
4121
  fi
3900
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.
3901
4136
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3902
- --json number,title,state,headRefName,author)" || exit $?
4137
+ --json number,title,state,headRefName,isDraft,url,updatedAt,author)" || exit $?
3903
4138
  pr_list_report_truncation github "$limit" "$state" \
3904
4139
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3905
4140
  printf '%s' "$_gh_raw" \
3906
- | 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 // "")}'
3907
4142
  fi
3908
4143
  else
3909
4144
  # `author` IS THE `nickname`, the one handle field a Bitbucket user
@@ -4324,7 +4559,19 @@ case "$op" in
4324
4559
  done
4325
4560
  limit_args=()
4326
4561
  [ -n "$limit" ] && limit_args=(--limit "$limit")
4327
- 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
4328
4575
  # Jira, resolved through the REST API — DISPATCHED ON `Tracker`, never on
4329
4576
  # `backend()`: a Bitbucket repo tracking in Jira is the normal enterprise
4330
4577
  # case, so the git host is irrelevant here (see the Jira helpers up top).
@@ -4502,7 +4749,15 @@ case "$op" in
4502
4749
  # 4 to `unsupported` and anything else to `failed` must not need a second
4503
4750
  # table to read this op.
4504
4751
  num="${1:?issue-view needs an issue number}"; shift
4505
- 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
4506
4761
  # Jira, dispatched on `Tracker` not `backend()` — the same rule issue-list
4507
4762
  # follows. `num` is a Jira KEY (PROJ-123), read off issue-list moments ago.
4508
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
+ }