@plot-pm/board 0.14.3 → 0.14.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/plot-budget.sh CHANGED
@@ -221,7 +221,7 @@ BUDGET_FALLBACK_WINDOW_MS=3600000
221
221
  # which is a reading about whichever pool was spent last — so a caller deciding
222
222
  # whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
223
223
  # and this is why.
224
- budget_rate() {
224
+ budget_rate_read() {
225
225
  local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
226
226
  local path
227
227
  [ -n "$now" ] || now="$(budget_now_ms)"
@@ -321,6 +321,171 @@ budget_rate() {
321
321
  ' "$path" 2>/dev/null || echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
322
322
  }
323
323
 
324
+ # THE SAME ANSWER IS SCANNED FOR ONCE PER PROCESS, and that is the whole of this
325
+ # memo. Measured on `quatico/quaweb-website` at Plot 2.19.0: `budget.tsv` holds
326
+ # 312589 lines across 17.6 MB, one read of it takes **516 ms** against 5 ms over
327
+ # fifty lines, and a single `pr-list` calls this three times for the same answer.
328
+ # The file is append-only and a process that asks twice is asking about the same
329
+ # window, so the second scan buys nothing a caller can observe.
330
+ #
331
+ # THE MEMO IS A FILE, AND A VARIABLE ALONE COULD NOT HAVE WORKED. Every caller
332
+ # in `plot-host.sh` writes `rate="$(budget_rate ...)"`, and a command
333
+ # substitution is a SUBSHELL: a variable the function sets inside one dies when
334
+ # the substitution closes. Measured on this branch with a counter — three
335
+ # substituted calls on one key scanned the ledger 3 times with a variable memo
336
+ # in place, against 1 for three direct calls. A variable memo is not slow at the
337
+ # call sites that exist, it is ABSENT from them, and every behavioural test
338
+ # still passes because the three answers are identical.
339
+ #
340
+ # `$$`, NOT `BASHPID`, IS THE SCOPE. `$$` is the invoking shell's pid and is
341
+ # deliberately NOT updated inside a subshell, so it names the process TREE —
342
+ # which is exactly "the life of the process" this memo is bounded by. `BASHPID`
343
+ # tracks the fork and would give every substitution its own empty cache, which
344
+ # is the variable memo's defect with an extra file write.
345
+ #
346
+ # THE COST IS PAID BACK ABOUT TWO THOUSAND TIMES. Measured here: a builtin
347
+ # `read` of one memo line is 82 us and a write 153 us, against 188 ms for one
348
+ # scan of a 40000-line ledger and the 516 ms reported above. The read is a
349
+ # builtin with no fork, which is what keeps it in that range.
350
+ #
351
+ # PER PROCESS IS THE WHOLE BOUND. There is no TTL, no `stat` of the record and
352
+ # no invalidation hook, because a `plot-host.sh` invocation is short-lived and a
353
+ # memo that tried to notice the file moving would re-`stat` on every call — the
354
+ # cost this exists to remove, re-introduced in a smaller form. `budget_append`
355
+ # writes between two reads and the second read is deliberately served the first
356
+ # one's answer.
357
+ #
358
+ # THE KEY IS THE TRIPLE, NEVER ONE FIELD OF IT. An EMPTY bucket means *every
359
+ # bucket* and is a different question from any named one — `budget_rate_read`
360
+ # says so in its own words above, and the two live in one process: measured with
361
+ # a probe, one `pr-list` asks `(github,jwloka,'')` for the concurrency bound and
362
+ # `(github,jwloka,graphql)` for the transport choice. A memo keyed on less than
363
+ # the triple answers the first with the second's reading.
364
+ #
365
+ # AN EXPLICIT `now` BYPASSES THE MEMO ENTIRELY, and a reader will ask why. It is
366
+ # a FOURTH question rather than a fourth key: a caller naming a moment is asking
367
+ # what the record looked like THEN, and every window boundary above is computed
368
+ # from it. Keying on it would make every lookup a miss, because the callers that
369
+ # omit it get `budget_now_ms()` and differ by milliseconds — a memo that is dead
370
+ # code and still passes every behavioural test. Serving a memo across different
371
+ # `now` values would answer a named moment with another one's window. Only the
372
+ # callers that omit it — every one in `plot-host.sh` — are memoised.
373
+ #
374
+ # NOT-YET-COMPUTED AND COMPUTED-TO-EMPTY ARE TWO STATES, and the FILE'S
375
+ # EXISTENCE is what separates them. The zero object is a well-formed answer for
376
+ # a missing record and for a `budget_path` that failed, so it is CACHED like any
377
+ # other — a memo that treated it as no-answer-worth-keeping would restore the
378
+ # scan on exactly the machines with nothing to scan. The entry is never empty:
379
+ # `budget_rate_read` prints a JSON object on every path, so a zero-byte entry
380
+ # means a torn write and is re-read rather than served.
381
+ #
382
+ # PUBLISHED BY `mv`, NEVER BY `>`, for `budget_slot_acquire`'s reason one
383
+ # paragraph down: a redirect creates the NAME before the CONTENT, so a sibling
384
+ # subshell can open the file and read half an answer. The rename is atomic
385
+ # within a directory, so the name and the answer arrive together.
386
+ budget_rate() {
387
+ local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
388
+
389
+ # A caller that named a moment is asking a different question. Straight
390
+ # through, neither read nor written.
391
+ if [ -n "$now" ]; then
392
+ budget_rate_read "$connector" "$account" "$bucket" "$now"
393
+ return $?
394
+ fi
395
+
396
+ local key slot
397
+ key="${connector}|${account}|${bucket}"
398
+ # Every character a variable name may not hold becomes `_`. Two distinct
399
+ # triples could collide only by differing in punctuation alone, which no
400
+ # connector, account or bucket name does.
401
+ slot="_budget_rate_memo_$(printf '%s' "$key" | LC_ALL=C tr -c '[:alnum:]_' '_')"
402
+
403
+ # TIER ONE, FREE: the same shell asking twice. SET, NOT NON-EMPTY — the zero
404
+ # object is a real answer and an empty one is a state this memo never stores.
405
+ if eval "[ -n \"\${${slot}+set}\" ]"; then
406
+ eval "printf '%s\\n' \"\${${slot}}\""
407
+ return 0
408
+ fi
409
+
410
+ # TIER TWO, 82 us: a subshell asking what its parent already asked. This is
411
+ # the tier that fires at every call site `plot-host.sh` actually has.
412
+ local dir file answer
413
+ dir="$(budget_memo_dir)" || dir=''
414
+ if [ -n "$dir" ]; then
415
+ file="$dir/$slot"
416
+ # `-s`, NOT `-f`. A zero-byte entry is a torn write, never an answer.
417
+ if [ -s "$file" ]; then
418
+ IFS= read -r answer < "$file" 2>/dev/null || answer=''
419
+ if [ -n "$answer" ]; then
420
+ eval "${slot}=\$answer"
421
+ printf '%s\n' "$answer"
422
+ return 0
423
+ fi
424
+ fi
425
+ fi
426
+
427
+ local rc
428
+ # THE EXIT CODE IS THE READ'S, never this wrapper's. `budget_rate_read`
429
+ # returns 0 on every path today and its callers test the CONTENT, so a memo
430
+ # that invented an exit status would be the one place the two disagree.
431
+ answer="$(budget_rate_read "$connector" "$account" "$bucket")"; rc=$?
432
+ if [ "$rc" -ne 0 ]; then
433
+ # A read that failed is not an answer, so nothing is remembered: the next
434
+ # caller asks again rather than inheriting a failure for the process life.
435
+ printf '%s\n' "$answer"
436
+ return "$rc"
437
+ fi
438
+ eval "${slot}=\$answer"
439
+ # NEVER FAILS ITS CALLER, `budget_append`'s rule for the same reason: the memo
440
+ # is an optimisation beside an answer that is already correct, so a cache that
441
+ # cannot be written must not turn a good reading into a failed one.
442
+ if [ -n "$dir" ] && mkdir -p "$dir" 2>/dev/null; then
443
+ if printf '%s\n' "$answer" >"$file.$BASHPID.tmp" 2>/dev/null; then
444
+ mv -f "$file.$BASHPID.tmp" "$file" 2>/dev/null || rm -f "$file.$BASHPID.tmp" 2>/dev/null || true
445
+ fi
446
+ fi
447
+ printf '%s\n' "$answer"
448
+ return 0
449
+ }
450
+
451
+ # Where THIS PROCESS's memoised rates live, and it is deliberately not beside
452
+ # the record. `$PLOT_BUDGET_HOME/memo/<pid>` — same override as the record and
453
+ # the slots, so a test pointing `PLOT_BUDGET_HOME` at a sandbox gets a sandboxed
454
+ # cache too, and a `$PLOT_BUDGET_HOME` that cannot be resolved means no cache
455
+ # rather than a cache in the wrong place.
456
+ #
457
+ # `$$` IS THE DIRECTORY NAME because it is the one identifier that is stable
458
+ # across a command substitution — see `budget_rate` above, where that property
459
+ # is the whole reason this file exists. A pid is reused by the kernel after the
460
+ # process ends, so an entry could in principle be inherited by a later,
461
+ # unrelated process holding the same pid. That is bounded by `budget_memo_clear`
462
+ # below, which the adapter calls on exit, and it is why the cache holds a
463
+ # DERIVED reading rather than anything a caller could act on irreversibly: the
464
+ # worst case is one stale rate, which is the same staleness the memo grants
465
+ # within a process by design.
466
+ budget_memo_dir() {
467
+ local home="${PLOT_BUDGET_HOME:-}"
468
+ if [ -z "$home" ]; then
469
+ [ -n "${HOME:-}" ] || return 1
470
+ home="$HOME/.plot/state"
471
+ fi
472
+ printf '%s\n' "$home/memo/$$"
473
+ }
474
+
475
+ # Removes this process's memo directory. Called on exit by the adapter, so a
476
+ # long-lived machine does not accumulate one directory per `plot-host.sh` call.
477
+ #
478
+ # NEVER FAILS ITS CALLER. It runs in a trap beside work that has already
479
+ # happened, and a cache that cannot be cleared must not change an exit status.
480
+ budget_memo_clear() {
481
+ local dir
482
+ dir="$(budget_memo_dir)" || return 0
483
+ case "$dir" in
484
+ */memo/[0-9]*) rm -rf "$dir" 2>/dev/null || true ;;
485
+ esac
486
+ return 0
487
+ }
488
+
324
489
  # ── The concurrency bound ────────────────────────────────────────────────────
325
490
  #
326
491
  # HOW MANY CALLS THIS ACCOUNT HAS OPEN AT ONCE, bounded across PROCESSES. The
package/plot-host.sh CHANGED
@@ -61,6 +61,30 @@
61
61
  # pr-ready <number> take a PR out of draft
62
62
  # merge the PR
63
63
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
64
+ # [--since <iso>] narrows the listing to pull
65
+ # requests the host has seen change since that
66
+ # stamp. Measured 2026-09-21 on this repository:
67
+ # one `--state all` over 933 pull requests takes
68
+ # 29 811 ms with the fields the board needs, and
69
+ # the same call over one day takes 943 ms for 3
70
+ # rows — factor 32, with every expensive field
71
+ # still included.
72
+ # THE STAMP IS THE HOST'S OWN, passed through
73
+ # byte-for-byte: a caller that re-renders it
74
+ # sends its own clock's spelling, and a client
75
+ # two seconds fast excludes the PRs updated in
76
+ # that gap from every later window — forever,
77
+ # because the window never reopens.
78
+ # GITHUB NARROWS AND BITBUCKET'S BULK LISTING
79
+ # CANNOT. `gh pr list` takes `--search`; `bb pr
80
+ # list` has no query flag at all (verified
81
+ # against bb 1.9.0: `unknown flag: --query`), so
82
+ # only the per-branch sweep's REST `q=` can
83
+ # carry it. The bulk Bitbucket listing SAYS it
84
+ # could not narrow and answers in full, because
85
+ # a full answer reported as a delta is what
86
+ # would let a caller advance a watermark over a
87
+ # window it never applied.
64
88
  # [--repo <owner/repo>] pins the list to ONE
65
89
  # repository, exactly as pr-state and pr-merged
66
90
  # do. A checkout with remotes on two hosts lets
@@ -70,7 +94,24 @@
70
94
  # JSON lines: {"number":N,"title":"...",
71
95
  # "state":"...","head":"..."}
72
96
  # --rich adds: draft, checks, mergeable, review,
73
- # url, failing_checks — `failing_checks` names
97
+ # url, updatedAt, failing_checks —
98
+ # `updatedAt` is WHEN THE HOST LAST SAW THE PR
99
+ # CHANGE, in the host's own words and on the
100
+ # host's own clock (`updatedAt` on GitHub,
101
+ # `updated_on` on Bitbucket), "" where the CLI
102
+ # omits it. It is the field a durable store
103
+ # advances its watermark by, and it is the
104
+ # host's rather than this machine's because a
105
+ # client clock two seconds fast would exclude
106
+ # every PR updated in that gap from every later
107
+ # window — permanently, and silently.
108
+ # Measured 2026-09-21: over 937 PRs,
109
+ # `number,updatedAt` is 4715 ms against 5417 ms
110
+ # for the base fields and 18842 ms for
111
+ # `statusCheckRollup`. Asked on --rich ONLY —
112
+ # the plain arm is the cheap one and no shell
113
+ # caller reads a watermark.
114
+ # `failing_checks` names
74
115
  # WHICH checks failed, the detail `checks`
75
116
  # collapses to one word, from the same response
76
117
  # at no extra call; [] on bitbucket and wherever
@@ -718,6 +759,24 @@ bb_branch_query() { # $1=branch $2=adapter state; rest=global bb args → values
718
759
  local _br="$1" _st="$2"; shift 2
719
760
  local _q _path _out _rc
720
761
  _q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND source.branch.name=$(url_encode "\"$_br\"")"
762
+ # THE WINDOW COMPOSES WITH THE STATE FILTER AND NEVER REPLACES IT. Bitbucket
763
+ # takes ONE `q=` parameter, so a second one would silently win or lose
764
+ # depending on the host's parsing — and either way the state clause this query
765
+ # is built around would be the term at risk. `AND` is the same conjunction the
766
+ # two terms above already use.
767
+ #
768
+ # ENCODED LIKE EVERY OTHER TERM ON THIS PATH, and an ISO stamp needs it: `:`
769
+ # and `-` are not in `url_encode`'s safe set, and the block header records
770
+ # what an unencoded value costs — `/` ends the filter early, so the query asks
771
+ # about one thing and answers about another.
772
+ #
773
+ # `>=` RATHER THAN `>`, and the asymmetry with the GitHub arm is deliberate.
774
+ # Bitbucket's `updated_on` carries microseconds and GitHub's stamp does not,
775
+ # so an exclusive comparison against a truncated stamp would drop a PR updated
776
+ # inside the same second. Re-reading one row the caller already holds costs a
777
+ # row; missing one costs a change nobody ever sees again.
778
+ [ -n "$PR_LIST_SINCE" ] \
779
+ && _q="$_q AND updated_on>=$(url_encode "\"$PR_LIST_SINCE\"")"
721
780
  # The space between the two terms is encoded too; `bb api` passes the path to
722
781
  # curl verbatim and an unencoded space would truncate the request line.
723
782
  _q="${_q// /%20}"
@@ -824,6 +883,19 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
824
883
  # separator is git's guarantee rather than a hopeful convention.
825
884
  PR_LIST_BRANCHES=""
826
885
 
886
+ # The window a sweep's query carries, the host's own stamp. Empty means ask
887
+ # about everything, which is what every caller predating `--since` asks.
888
+ #
889
+ # A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
890
+ # `pr-list` arm sets it immediately before the call, `bb_branch_sweep` passes
891
+ # through without reading it, and `bb_branch_query` composes it into the one
892
+ # `q=` Bitbucket takes. Threading it through the sweep as an argument would mean
893
+ # teaching that function a parameter it only forwards, and its header already
894
+ # refuses the mirror of that — "teaching `pr_list_states` which of its commands
895
+ # is a sweep would put a backend's shape inside the one piece of this file that
896
+ # has none."
897
+ PR_LIST_SINCE=""
898
+
827
899
  # How many branches the last sweep asked about, and how many answered.
828
900
  #
829
901
  # THE COMPLETENESS SIGNAL, AND WHY THE ARM STATES IT RATHER THAN THE SCAN
@@ -2469,6 +2541,18 @@ backend() {
2469
2541
  # written beside it.
2470
2542
  . "$here/plot-budget.sh"
2471
2543
 
2544
+ # THE MEMO IS SWEPT ON THE WAY OUT. `budget_rate` caches its reading under
2545
+ # `$PLOT_BUDGET_HOME/memo/$$` because a command substitution is a subshell and a
2546
+ # variable set inside one does not survive it — see the block above
2547
+ # `budget_rate`. A directory keyed on a pid must be removed by the process that
2548
+ # made it, or a long-lived machine accumulates one per `plot-host.sh` call.
2549
+ #
2550
+ # `EXIT` ALONE, deliberately. It runs on a normal return and on an uncaught
2551
+ # signal's default termination path is irrelevant here: the sweep is an
2552
+ # optimisation's housekeeping, and a cache that outlives one run costs a stale
2553
+ # reading at worst, which is the same staleness the memo grants by design.
2554
+ trap 'budget_memo_clear' EXIT
2555
+
2472
2556
  # WHO IS SPENDING — read from the CLI's own config, never from an API call.
2473
2557
  #
2474
2558
  # `gh api user` would answer authoritatively and cost one request against the
@@ -3302,12 +3386,22 @@ case "$op" in
3302
3386
  # existing caller's result changes.
3303
3387
  limit=""
3304
3388
  branches=""
3389
+ # THE WINDOW, AND IT IS THE HOST'S OWN STAMP. Empty means ask about
3390
+ # everything, which is what every caller predating this asks and what a
3391
+ # caller with no stored watermark must ask. A caller that has one sends it
3392
+ # verbatim — see the header: re-rendering it is how a window closes forever.
3393
+ since=""
3305
3394
  while [ $# -gt 0 ]; do
3306
3395
  case "$1" in
3307
3396
  --state) state="${2:?}"; shift 2 ;;
3308
3397
  --limit) limit="${2:?}"; shift 2 ;;
3309
3398
  --rich) rich=1; shift ;;
3310
3399
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3400
+ # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3401
+ # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
3402
+ # a syntax error the host may answer with everything or with nothing —
3403
+ # and a caller whose watermark was null would send exactly that.
3404
+ --since) since="${2:?}"; shift 2 ;;
3311
3405
  # THE BRANCHES THE CALLER TRACKS, repeatable, and OPT-IN. Given any,
3312
3406
  # the Bitbucket arm sweeps the REST endpoint once per branch per state
3313
3407
  # instead of listing the repository; given none, every existing caller
@@ -3328,6 +3422,23 @@ case "$op" in
3328
3422
  done
3329
3423
  limit_args=()
3330
3424
  [ -n "$limit" ] && limit_args=(--limit "$limit")
3425
+ # THE WINDOW AS GITHUB TAKES IT, built once and appended at all three call
3426
+ # sites — the same argument `pr_list_call`'s header makes about the six
3427
+ # hand-applied fixes: three sites each composing their own search string is
3428
+ # three places for the next window's shape to drift.
3429
+ #
3430
+ # `updated:>` is EXCLUSIVE, and that is the safe direction here. The
3431
+ # watermark is the stamp of a row the caller has ALREADY stored, so
3432
+ # excluding it re-asks nothing; including it would re-fetch that row every
3433
+ # pass for no new information. A PR updated in the same second as the
3434
+ # watermark is the one this can miss, and the periodic full read the caller
3435
+ # is required to keep making is what corrects it.
3436
+ #
3437
+ # THE STAMP IS NOT QUOTED INSIDE THE QUERY. `gh` sends `--search`'s value as
3438
+ # one API parameter and an ISO-8601 stamp carries no space, so a quote would
3439
+ # travel to GitHub as part of the term and match nothing.
3440
+ search_args=()
3441
+ [ -n "$since" ] && search_args=(--search "updated:>$since")
3331
3442
 
3332
3443
  # --- Jenkins CI integration (orthogonal to Git host) ---
3333
3444
  # When `CI: jenkins` is configured, build status comes from Jenkins rather
@@ -3438,8 +3549,8 @@ case "$op" in
3438
3549
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
3439
3550
  # $jentry == null → the branch has no Jenkins job; `none`.
3440
3551
  # otherwise → the joined colour's `checks`, job named on fail.
3441
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3442
- --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3552
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3553
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
3443
3554
  pr_list_report_truncation github "$limit" "$state" \
3444
3555
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3445
3556
  printf '%s' "$_gh_raw" \
@@ -3459,6 +3570,7 @@ case "$op" in
3459
3570
  else "unknown" end),
3460
3571
  review:(.reviewDecision // ""),
3461
3572
  url:.url,
3573
+ updatedAt:(.updatedAt // ""),
3462
3574
  failing_checks:(
3463
3575
  if $jentry != null and $jentry.checks == "failing"
3464
3576
  then [$jentry.job]
@@ -3467,8 +3579,8 @@ case "$op" in
3467
3579
  }'
3468
3580
  else
3469
3581
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
3470
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3471
- --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3582
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3583
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
3472
3584
  pr_list_report_truncation github "$limit" "$state" \
3473
3585
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3474
3586
  printf '%s' "$_gh_raw" \
@@ -3490,6 +3602,7 @@ case "$op" in
3490
3602
  else "unknown" end),
3491
3603
  review:(.reviewDecision // ""),
3492
3604
  url:.url,
3605
+ updatedAt:(.updatedAt // ""),
3493
3606
  failing_checks:[
3494
3607
  .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
3495
3608
  | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
@@ -3498,7 +3611,7 @@ case "$op" in
3498
3611
  }'
3499
3612
  fi
3500
3613
  else
3501
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3614
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3502
3615
  --json number,title,state,headRefName)" || exit $?
3503
3616
  pr_list_report_truncation github "$limit" "$state" \
3504
3617
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -3543,6 +3656,23 @@ case "$op" in
3543
3656
  # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3544
3657
  # began. One assignment here; the sites are untouched but for this word.
3545
3658
  PR_LIST_BRANCHES="$branches"
3659
+ # THE WINDOW REACHES THE SWEEP AND NOT THE LISTING, and the asymmetry is
3660
+ # the CLI's rather than a choice. `bb pr list` takes `--state`, `--author`,
3661
+ # `--json` and `--jq` and no query flag at all — verified against bb 1.9.0,
3662
+ # which answers `unknown flag: --query` — so the only Bitbucket path that
3663
+ # can carry `updated_on` is `bb_branch_query`'s own REST `q=`.
3664
+ #
3665
+ # SAID RATHER THAN SWALLOWED, for the reason `--limit` two blocks up is
3666
+ # said: a caller that asked for a window and got a full listing must not
3667
+ # read the answer as a delta. It would advance its watermark over a window
3668
+ # it never applied — harmless this pass, since a full listing holds every
3669
+ # row a narrow one would, and wrong the moment the caller uses the flag to
3670
+ # decide whether its answer was complete.
3671
+ PR_LIST_SINCE="$since"
3672
+ if [ -n "$since" ] && [ -z "$branches" ]; then
3673
+ echo "plot-host: bitbucket ignores --since $since on a listing; bb pr list has no query flag (bb 1.9.0) — answering in full" >&2
3674
+ PR_LIST_SINCE=""
3675
+ fi
3546
3676
  PR_SWEEP_ASKED=0
3547
3677
  bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
3548
3678
  if [ -n "$branches" ]; then
@@ -3574,6 +3704,7 @@ case "$op" in
3574
3704
  mergeable:"unknown",
3575
3705
  review:"",
3576
3706
  url:(.links.html.href // ""),
3707
+ updatedAt:(.updated_on // ""),
3577
3708
  failing_checks:(
3578
3709
  if $jentry != null and $jentry.checks == "failing"
3579
3710
  then [$jentry.job]
@@ -3584,7 +3715,7 @@ case "$op" in
3584
3715
  # Bitbucket without Jenkins: checks remain unknown
3585
3716
  PR_LIST_JQ_ARGS=()
3586
3717
  pr_list_states bitbucket "$limit" "$bb_states" \
3587
- '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),failing_checks:[]}' \
3718
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),updatedAt:(.updated_on // ""),failing_checks:[]}' \
3588
3719
  "${bb_cmd[@]}" || exit $?
3589
3720
  fi
3590
3721
  else
package/plot-reap.sh CHANGED
@@ -385,11 +385,19 @@ manifest_for() {
385
385
  reap=0; kept=0; removed=0; cleared=0; vanished=0; unplaced=0
386
386
  printf '%-8s %-52s %s\n' "verdict" "branch" "why"
387
387
 
388
- while IFS=$'\t' read -r wt br prunable; do
388
+ while IFS=$'\037' read -r wt br prunable; do
389
389
  [ -n "$wt" ] || continue
390
390
  short=${br#refs/heads/}
391
391
  [ "$wt" = "$ROOT" ] && continue
392
392
 
393
+ # WHAT THE REPORT CALLS THIS TREE. `short` is the branch and stays empty for
394
+ # a detached desk, which is the correct reading and a useless label — an
395
+ # operator reading a blank column cannot tell which of thirteen it names.
396
+ # The directory is what identifies such a tree, so the report says so and
397
+ # every refusal still measures `short`.
398
+ label=$short
399
+ [ -z "$label" ] && label="(detached) $(basename "$wt")"
400
+
393
401
  # 4a. GIT'S OWN ANSWER THAT THE DIRECTORY IS GONE, and it is a REPORT rather
394
402
  # than a sixth refusal. The five below each say *do not remove this* and
395
403
  # send an operator to look; this says *there is nothing to remove and the
@@ -414,7 +422,7 @@ while IFS=$'\t' read -r wt br prunable; do
414
422
  # prune stays the operator's decision — the same discipline that makes
415
423
  # every refusal a measurement rather than an act.
416
424
  if [ "$prunable" = "yes" ]; then
417
- printf '%-8s %-52s %s\n' "vanished" "$short" "directory gone — 'git worktree prune' clears the entry"
425
+ printf '%-8s %-52s %s\n' "vanished" "$label" "directory gone — 'git worktree prune' clears the entry"
418
426
  vanished=$((vanished+1)); continue
419
427
  fi
420
428
 
@@ -482,7 +490,7 @@ while IFS=$'\t' read -r wt br prunable; do
482
490
  # population the rule excludes. It counts, because a refusal that counts is
483
491
  # the difference between *nothing to clean* and *nothing was looked at*.
484
492
  if [ "$unclassified" = true ]; then
485
- printf '%-8s %-52s %s\n' "unknown" "$short" \
493
+ printf '%-8s %-52s %s\n' "unknown" "$label" \
486
494
  "under $(basename "$WT_ROOT")/, no worker pid and no recognised name — needs a person"
487
495
  unplaced=$((unplaced+1)); continue
488
496
  fi
@@ -531,6 +539,25 @@ while IFS=$'\t' read -r wt br prunable; do
531
539
  merge=merged; why="merged into $DEFAULT"
532
540
  elif [ -n "$short" ] && pr_merged "$short"; then
533
541
  merge=merged; why="PR merged (squash)"
542
+ elif [ -z "$short" ] \
543
+ && [ "$(git -C "$wt" rev-list --count "origin/$DEFAULT..HEAD" 2>/dev/null || echo 1)" = "0" ]; then
544
+ # A DETACHED DESK HAS NOTHING TO LAND, and that is a measurement rather
545
+ # than a weakened refusal. `plot-dispatch.sh --start` cuts a free agent's
546
+ # desk detached at `origin/<main>` because a free agent holds no slice, so
547
+ # such a tree never had a branch and can never have a PR. Reading it
548
+ # through `no-merged-pr` would refuse every one of them forever — the same
549
+ # silence this slice removes, in the opposite direction.
550
+ #
551
+ # THE READING IS `HEAD` AGAINST THE DEFAULT BRANCH, never the desk's name.
552
+ # Measured 2026-09-22: three `free-*` desks hold a branch and two carry
553
+ # live workers (pids 27820, 6542), so a prefix-keyed test reaps a running
554
+ # agent. Detachment is the condition; the name is not.
555
+ #
556
+ # A detached desk carrying commits falls through to `not-merged` and is
557
+ # kept, which is correct: somebody committed there and nothing says the
558
+ # work landed. The live-pid, marker and dirty refusals are all asked
559
+ # before this and are unaffected.
560
+ merge=merged; why="detached, nothing to land"
534
561
  fi
535
562
 
536
563
  # THE DECISION. One call, and the script holds no `if` about whether a
@@ -599,11 +626,11 @@ NODE_EOF
599
626
  no-merged-pr) reason="unlanded work — no merged PR" ;;
600
627
  *) reason="rule could not be asked — keeping" ;;
601
628
  esac
602
- printf '%-8s %-52s %s\n' "keep" "$short" "$reason"; kept=$((kept+1)); continue
629
+ printf '%-8s %-52s %s\n' "keep" "$label" "$reason"; kept=$((kept+1)); continue
603
630
  fi
604
631
 
605
632
  if [ "$MAX" -gt 0 ] && [ "$reap" -ge "$MAX" ]; then
606
- printf '%-8s %-52s %s\n' "keep" "$short" "--max $MAX reached"; kept=$((kept+1)); continue
633
+ printf '%-8s %-52s %s\n' "keep" "$label" "--max $MAX reached"; kept=$((kept+1)); continue
607
634
  fi
608
635
 
609
636
  # Resolved BEFORE the removal, because `canonical` needs the directory to
@@ -619,7 +646,7 @@ NODE_EOF
619
646
 
620
647
  reap=$((reap+1))
621
648
  if [ "$DRY" -eq 1 ]; then
622
- printf '%-8s %-52s %s\n' "would" "$short" "$why${logs:+, log $logs}"
649
+ printf '%-8s %-52s %s\n' "would" "$label" "$why${logs:+, log $logs}"
623
650
  else
624
651
  if git worktree remove --force "$wt" 2>/dev/null; then
625
652
  # The worktree is gone; NOW the manifest may go. Inside the success arm
@@ -647,16 +674,16 @@ NODE_EOF
647
674
  while IFS= read -r f; do rm -f "$f" 2>/dev/null; done < <(branch_log_files "$short")
648
675
  why="$why, log removed"
649
676
  fi
650
- printf '%-8s %-52s %s\n' "reaped" "$short" "$why"; removed=$((removed+1))
677
+ printf '%-8s %-52s %s\n' "reaped" "$label" "$why"; removed=$((removed+1))
651
678
  else
652
- printf '%-8s %-52s %s\n' "FAILED" "$short" "git worktree remove refused"; kept=$((kept+1))
679
+ printf '%-8s %-52s %s\n' "FAILED" "$label" "git worktree remove refused"; kept=$((kept+1))
653
680
  fi
654
681
  fi
655
682
  done < <(git worktree list --porcelain \
656
- | awk '/^worktree /{ if (br != "") print p"\t"br"\t"pr; p=$2; br=""; pr="no"; next }
683
+ | awk -v OFS="\037" '/^worktree /{ if (p != "") print p, br, pr; p=$2; br=""; pr="no"; next }
657
684
  /^branch / { br=$2; next }
658
685
  /^prunable/ { pr="yes"; next }
659
- END { if (br != "") print p"\t"br"\t"pr }')
686
+ END { if (p != "") print p, br, pr }')
660
687
 
661
688
  [ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
662
689