@plot-pm/board 0.16.0 → 0.16.2

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.
@@ -2844,6 +2844,7 @@ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2844
2844
  # gave when it failed.
2845
2845
  plan_meta_files=()
2846
2846
  plan_meta_phases=()
2847
+ plan_meta_types=()
2847
2848
  plan_meta_waves=()
2848
2849
 
2849
2850
  # Parses every plan file given, filling the four arrays above. Called ONCE.
@@ -2871,7 +2872,7 @@ for line in sys.stdin:
2871
2872
  f = d.get("file")
2872
2873
  if not f:
2873
2874
  continue
2874
- print("\t".join(["P", clean(f), clean(d.get("phase", ""))]))
2875
+ print("\t".join(["P", clean(f), clean(d.get("phase", "")), clean(d.get("type", ""))]))
2875
2876
  for i, w in enumerate(d.get("waves", []) or []):
2876
2877
  name = w.get("name")
2877
2878
  for b in w.get("branches", []) or []:
@@ -2906,8 +2907,12 @@ for line in sys.stdin:
2906
2907
  case "$kind" in
2907
2908
  P)
2908
2909
  plan_meta_files+=("$file")
2909
- # `rest` is "<phase>", a single token with no tabs.
2910
- plan_meta_phases+=("$rest")
2910
+ # `rest` is "<phase>\t<type>", two tokens with no tabs inside either.
2911
+ plan_meta_phases+=("${rest%%$'\t'*}")
2912
+ case "$rest" in
2913
+ *$'\t'*) plan_meta_types+=("${rest#*$'\t'}") ;;
2914
+ *) plan_meta_types+=("") ;;
2915
+ esac
2911
2916
  plan_meta_waves+=("")
2912
2917
  ;;
2913
2918
  W)
@@ -3659,6 +3664,11 @@ for plan in "${plans[@]}"; do
3659
3664
  if is_terminal_phase "$plan_phase" && [ "$plan_phase" != "delivered" ]; then
3660
3665
  continue
3661
3666
  fi
3667
+ # A docs or infra plan is live when it merges: /plot-release never records
3668
+ # `Released` for one, so `delivered` is its last phase and it leaves here.
3669
+ if [ "$plan_phase" = "delivered" ]; then
3670
+ case "${plan_meta_types[$meta_i]}" in docs|infra) continue ;; esac
3671
+ fi
3662
3672
 
3663
3673
  n_plans=$((n_plans + 1))
3664
3674
  plan_target=$(readlink "$plan" 2>/dev/null && echo "" || true)
package/plot-host.sh CHANGED
@@ -247,7 +247,9 @@
247
247
  # list` (no --json), pinned to bb 0.6.0. EXIT 4
248
248
  # narrows rather than disappears: it is the
249
249
  # tracker-DISABLED case (bb answers 404/410),
250
- # which stays *this host cannot answer* where an
250
+ # and a bb whose `--help` lists no `issue`
251
+ # command (Quatico bb), both of
252
+ # which stay *this host cannot answer* where an
251
253
  # empty list would say *there are none*. A call
252
254
  # that failed on an enabled tracker, or any error
253
255
  # wording this adapter does not recognise, exits
@@ -281,7 +283,8 @@
281
283
  # Same three outcomes as issue-list, same codes:
282
284
  # BITBUCKET NOW ANSWERS via `bb issue view`
283
285
  # (pinned to 0.6.0); `url` comes from the view's
284
- # footer. EXIT 4 is the tracker-DISABLED case,
286
+ # footer. EXIT 4 is the tracker-DISABLED case
287
+ # or a bb with no `issue` command,
285
288
  # EXIT 3 a lookup that failed or an unrecognised
286
289
  # error. An issue that does not exist is a
287
290
  # FAILURE here, not an empty body: the caller
@@ -1016,7 +1019,7 @@ bb_state_listing() { # global bb args… --state <s> --json → one JSON array
1016
1019
  esac
1017
1020
  done
1018
1021
  [ -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 $?
1022
+ _out="$(bb ${_args[@]+"${_args[@]}"} api "/repositories/{ws}/{repo}/pullrequests?state=$(bb_query_state "$_st")&pagelen=$BB_LIST_PAGELEN")" || return $?
1020
1023
  printf '%s' "$_out" | jq -c '.values // []'
1021
1024
  }
1022
1025
 
@@ -2033,17 +2036,41 @@ bb_issue_exit_code() {
2033
2036
  # fails LOUDLY rather than silently mis-reading a column that may have moved.
2034
2037
  # `PLOT_BB_SKIP_VERSION_CHECK` exists for the test harness, whose stub bb has no
2035
2038
  # meaningful version — the parse is exercised against captured fixture text.
2039
+ #
2040
+ # A VERSION MISMATCH IS NOT ALWAYS A MOVED FORMAT. Two products share the name
2041
+ # `bb` (see the capability check below), and Quatico `bb` — 1.9.0 measured
2042
+ # 2026-10-01 — has no `issue` command at all: `bb --help` lists `pr …`,
2043
+ # `source …` and `api`. Telling it to update would suggest a fix that does not
2044
+ # exist, so a mismatch first asks `bb --help` what the CLI offers. A help text
2045
+ # that lists a `pr` command and no `issue` command is a bb that cannot be asked
2046
+ # about issues, which is exit 4 — the same answer as a disabled tracker. Any
2047
+ # other help text keeps the refusal below, exit 3. Exits 4 or 3; 0 passes.
2036
2048
  bb_assert_issue_version() {
2037
2049
  [ -n "${PLOT_BB_SKIP_VERSION_CHECK:-}" ] && return 0
2038
2050
  local v
2039
2051
  v="$(bb --version 2>/dev/null | bb_strip_ansi | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
2040
2052
  if [ "$v" != "$BB_ISSUE_VERSION" ]; then
2053
+ if bb_lacks_issue_command; then
2054
+ 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
2055
+ return 4
2056
+ fi
2041
2057
  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
2058
  return 3
2043
2059
  fi
2044
2060
  return 0
2045
2061
  }
2046
2062
 
2063
+ # Whether `bb --help` lists commands and `issue` is not one of them. A help
2064
+ # that lists no `pr` command either is not a command listing this can read, so
2065
+ # it answers *not proven* (1) and the caller keeps its exit-3 refusal: guessing
2066
+ # 4 from unreadable help would turn a broken bb into *no issue tracker*.
2067
+ bb_lacks_issue_command() {
2068
+ local help
2069
+ help="$(bb --help 2>&1 | bb_strip_ansi)" || return 1
2070
+ grep -qE '^[[:space:]]+pr([[:space:]]|$)' <<<"$help" || return 1
2071
+ ! grep -qE '^[[:space:]]+issues?([[:space:]]|$)' <<<"$help"
2072
+ }
2073
+
2047
2074
  # --- bb capability check (--json support) ------------------------------------
2048
2075
  #
2049
2076
  # TWO TOOLS SHARE THE NAME `bb`. craftamap/bb is a Go binary that does NOT
@@ -2516,27 +2543,36 @@ jira_check() {
2516
2543
  # `bb` returned 50 merged PRs (ids 836→787) against a repo numbering to 836, so
2517
2544
  # ~780 older merged PRs were invisible to the join.
2518
2545
  #
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:
2546
+ # THE RULE IS `rules/listing-page.ts`'s `pagePossiblyTruncated`, and this is
2547
+ # its shell copy. `pr-list` runs on every board refresh, so the shell keeps the
2548
+ # rule rather than asking a bundle (docs/shell-and-domain.md), and
2549
+ # `packages/domain/corpus/listing-page.corpus.test.ts` holds the pair:
2522
2550
  #
2523
2551
  # 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.
2552
+ # AT LEAST the requested limit. Fewer rows than
2553
+ # the limit PROVES completeness.
2554
+ # bitbucket (ONE FIXED PAGE): the listing ignores --limit and returns one page
2555
+ # per state. A page SHORTER than the page length
2556
+ # is the last page, so it is complete. A page AT
2557
+ # the length stays possibly truncated.
2558
+ #
2559
+ # THE PAGE LENGTH IS A READING, NEVER A CONSTANT THE RULE NAMES. The listing
2560
+ # asks `pagelen=$BB_LIST_PAGELEN` through `bb api`, and the length holds only
2561
+ # where that `bb` passes the query through unchanged, so it is read per version
2562
+ # from `BB_LIST_PAGE_VERSIONS` — pinned like `BB_ISSUE_VERSION`. A version
2563
+ # nobody measured has no length, and every non-empty page under it stays
2564
+ # possibly truncated: the rule never calls a page complete it cannot prove. A
2565
+ # future length of 100 is a new table entry, never a page of 50 read as short.
2566
+ #
2567
+ # Measured on `quatico/quaweb-website`: bb 1.9.0 returned 50 merged rows in one
2568
+ # request on 2026-09-30, and on 2026-10-01 answered `pagelen: 50` with 50 rows,
2569
+ # a `next` and `size: 895`. Before
2570
+ # 2026-10-01 every non-empty Bitbucket page was reported, so 20 open PRs read as
2571
+ # truncated on every refresh (#1137).
2572
+ #
2573
+ # THE PER-BRANCH SWEEP AND THE WINDOWED LISTING MAKE NO PAGE CLAIM. The sweep
2574
+ # proves completeness per branch and the window refuses a short read, so this
2575
+ # detector is not asked about either (`pr_list_states`).
2540
2576
  #
2541
2577
  # No --limit was requested → the caller accepted the host's default page and is
2542
2578
  # owed no report, so no existing no-limit caller's behaviour changes.
@@ -2555,18 +2591,40 @@ jira_check() {
2555
2591
  # future diff that teaches the scan to fall back, without moving the failure
2556
2592
  # into a minutes-long pulse. See the plan's Done-when item 3.
2557
2593
  #
2594
+ # The page length Bitbucket's plain listing asks for. `bb_state_listing` sends
2595
+ # it, and `pr_list_report_truncation` reads it as the page length only for a
2596
+ # `bb` in BB_LIST_PAGE_VERSIONS.
2597
+ BB_LIST_PAGELEN=50
2598
+
2599
+ # The `bb` versions measured to pass `pagelen` through `bb api` unchanged,
2600
+ # space-separated. `adapters/host/listing-paging.ts` holds the same table.
2601
+ BB_LIST_PAGE_VERSIONS="1.9.0"
2602
+
2603
+ # The page length the answering `bb` lists by, or nothing where its version is
2604
+ # not in BB_LIST_PAGE_VERSIONS. Reads the version `bb_require_json` recorded,
2605
+ # so it costs no `bb` call; a skipped capability check records no version.
2606
+ bb_list_page_length() {
2607
+ local v="${BB_CAP_IDENTITY#*/}"
2608
+ case " $BB_LIST_PAGE_VERSIONS " in
2609
+ *" $v "*) printf '%s\n' "$BB_LIST_PAGELEN" ;;
2610
+ esac
2611
+ }
2612
+
2558
2613
  # $1 backend $2 requested limit (may be "") $3 state word $4 row count
2559
2614
  pr_list_report_truncation() {
2560
- local be="$1" limit="$2" state="$3" count="$4"
2615
+ local be="$1" limit="$2" state="$3" count="$4" len=""
2561
2616
  [ -n "$limit" ] || return 0 # no --limit → no completeness claim owed
2562
2617
  [ "$count" -gt 0 ] 2>/dev/null || return 0 # an empty page had nothing to hide
2563
2618
  if [ "$be" = "github" ]; then
2564
2619
  # github honours the limit: complete unless the page came back AT the limit.
2565
2620
  [ "$count" -ge "$limit" ] 2>/dev/null || return 0
2621
+ else
2622
+ # A fixed page: complete below a measured page length, unprovable otherwise.
2623
+ [ "$be" = "bitbucket" ] && len="$(bb_list_page_length)"
2624
+ if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then return 0; fi
2566
2625
  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.
2626
+ # Named per state so a caller can resolve exactly the states that were
2627
+ # capped, not a whole-call flag that over-reports.
2570
2628
  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
2629
  }
2572
2630
 
package/plot-reap.sh CHANGED
@@ -399,79 +399,35 @@ if [ -r "$CONFIG" ]; then
399
399
  fi
400
400
  case "$MANIFEST_DIR" in /*) ;; *) MANIFEST_DIR="$ROOT/$MANIFEST_DIR" ;; esac
401
401
 
402
- # Where the agent logs live, resolved through `plot-config.sh` from the SAME
403
- # `Worktree root` key `resolve_wt_root()` and `agentLogDir` read. Three readers
404
- # of one key, and none of them may invent a second: a reaper sweeping a
405
- # directory the board never writes to reports success over a file that is still
406
- # there, which is the failure this slice exists to stop.
402
+ # Where the DESKS and the agent logs live: the desk root, `deskRoot`'s answer
403
+ # asked through `plot-desk-root.sh` — the rule `plot-dispatch.sh` creates desks
404
+ # under and `agentLogDir` writes logs into. One rule, so a reaper cannot sweep a
405
+ # directory the board never writes to and report success over a file that is
406
+ # still there.
407
407
  #
408
- # THE FALLBACK IS THE PARENT DIRECTORY, NOT AN ERROR — `agentLogDir`'s rule,
409
- # stated the same way here. A repository with no key has no `.worktrees/`, and
410
- # the logs it wrote are beside it; a reaper that refused to look there would
411
- # clean nothing on exactly the repositories that never migrated.
408
+ # Two questions read the one answer. `LOG_DIR` is where a finished branch's log
409
+ # files are swept from; `WT_ROOT` is the directory a tree must sit under to be a
410
+ # candidate desk at all. The sweep's filename filter (`branch_log_files`) is
411
+ # unchanged, so what it may delete is still only `plot-<kind>-*` names. The
412
+ # default root is `<repo>/.worktrees`, which Plot owns, so a sibling checkout in
413
+ # the parent is never under it and stays silent.
412
414
  #
413
- # The case split is `resolve_wt_root()`'s: absolute taken as given, relative
414
- # joined onto the repo root, trailing slash trimmed as pure string work because
415
- # the directory need not exist. A second convention for resolving a configured
416
- # directory is a second way to be wrong.
417
- LOG_DIR="$(cd "$ROOT/.." && pwd)"
418
- if [ -r "$CONFIG" ]; then
419
- d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
420
- if [ -n "$d" ]; then
421
- case "$d" in
422
- /*) LOG_DIR="$d" ;;
423
- *) LOG_DIR="$ROOT/$d" ;;
424
- esac
425
- LOG_DIR="${LOG_DIR%/}"
426
- fi
427
- fi
428
-
429
- # Where the DESKS live, which is the same key resolved for a different question.
430
- #
431
- # `LOG_DIR` and this answer two things: `LOG_DIR` is where a finished branch's
432
- # log files are swept from, and this is the directory a tree must sit under to
433
- # be a candidate desk at all. A repository with NO configured root has no
434
- # `.worktrees/` and its desks are named `plot-wt-*` beside the repo — so this
435
- # stays EMPTY there rather than defaulting to the parent, because the parent
436
- # holds every sibling checkout a person ever made and calling those candidate
437
- # desks is the over-broad reading this slice must not introduce.
438
- #
439
- # IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`, and that is the one
440
- # place in this script where the two differ on purpose. `git rev-parse
415
+ # IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`. `git rev-parse
441
416
  # --show-toplevel` answers *this* worktree, so a reaper run from inside a desk
442
- # resolves `.worktrees` to a directory beneath that desk — which does not
417
+ # would resolve `.worktrees` to a directory beneath that desk — which does not
443
418
  # exist, so every tree reads as unplaceable-but-elsewhere and the reading is
444
- # silently empty. Measured 2026-09-10 from this worktree: `$ROOT/.worktrees`
419
+ # silently empty. Measured 2026-09-10 from inside a desk: `$ROOT/.worktrees`
445
420
  # named `.../plot-wt-feature-a-finished-desk-is-a-finding/.worktrees` while
446
421
  # every desk sits under `.../plot/.worktrees`. A dispatched agent runs the
447
422
  # reaper from exactly there, so the wrong answer would be the usual one.
448
423
  #
449
- # `--git-common-dir` is shared by every worktree of one repository, and its
450
- # parent is the primary checkout — which is what `plot-dispatch.sh` composes
451
- # desk paths from when it creates them. Same directory, same key, so a
452
- # creation and this reading cannot disagree.
453
- #
454
- # `LOG_DIR` above keeps `$ROOT` untouched: it is this script's established
455
- # behaviour with its own callers, and changing where a sweep DELETES from is a
456
- # blast radius rather than a reading. Named here rather than fixed silently.
457
- WT_ROOT=""
458
- if [ -r "$CONFIG" ]; then
459
- d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
460
- if [ -n "$d" ]; then
461
- case "$d" in
462
- /*) WT_ROOT="$d" ;;
463
- *)
464
- # Falls back to `$ROOT` where git cannot answer, which is the same
465
- # directory on a single-checkout repository and the only one available.
466
- main_checkout=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)") \
467
- || main_checkout="$ROOT"
468
- [ -d "$main_checkout" ] || main_checkout="$ROOT"
469
- WT_ROOT="$main_checkout/$d"
470
- ;;
471
- esac
472
- WT_ROOT="${WT_ROOT%/}"
473
- fi
474
- fi
424
+ # There is NO FALLBACK: a reaper that cannot ask the rule stops rather than
425
+ # compute a second default and delete from it.
426
+ # shellcheck source=plot-desk-root.sh
427
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-desk-root.sh"
428
+ WT_ROOT=$(plot_desk_root "$(plot_repo_root)") || {
429
+ echo "plot-reap: cannot resolve the desk root — nothing was reaped." >&2; exit 2; }
430
+ LOG_DIR="$WT_ROOT"
475
431
 
476
432
  # The files ONE branch's agent run leaves beside its worktree, removed with it.
477
433
  #
@@ -165,11 +165,10 @@ fi
165
165
  # MEASURED, and recorded at length in plot-dispatch.sh's held_worktree: a
166
166
  # hand-made worktree is named for the branch with its TYPE dropped, so a gate
167
167
  # that guessed `plot-wt-<flattened>` missed a worktree with six modified files
168
- # in it. This site had the same shape — it composed `plot-wt-<flattened>` under
169
- # `repo_root/..` — and this change would make the guess worse, not better: the
170
- # new `Worktree root:` key introduces a SECOND naming convention, giving a
171
- # path guess a second way to be wrong. So the read asks git, and only the
172
- # CREATE-a-fresh-one fallback below composes a name (via dispatch's rule, so the
168
+ # in it. Desks carry two naming conventions — `plot-wt-*` beside the repo from
169
+ # older dispatches, and unprefixed names under the desk root — so a path guess
170
+ # has two ways to be wrong. So the read asks git, and only the
171
+ # CREATE-a-fresh-one fallback below composes a name (under the desk root, so the
173
172
  # fresh worktree lands where dispatch would have put it).
174
173
  #
175
174
  # `git worktree list --porcelain` emits `worktree <path>` then `branch
@@ -182,21 +181,15 @@ wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/he
182
181
  /^worktree / { path = substr($0, 10) }
183
182
  /^branch / { if (substr($0, 8) == want) { print path; exit } }')
184
183
 
185
- # No existing worktree holds it — compose the path a fresh one will take, by the
186
- # same root+prefix rule plot-dispatch.sh uses. Under a `Worktree root:` key the
187
- # root moves and the `plot-wt-` prefix drops; absent it, today's behaviour.
184
+ # No existing worktree holds it — compose the path a fresh one will take, under
185
+ # the desk root `plot-dispatch.sh` also asks for, so the fresh worktree lands
186
+ # where dispatch would have put it. The root is the MAIN checkout's, and there
187
+ # is no fallback: an unaskable rule refuses the repair.
188
+ # shellcheck source=plot-desk-root.sh
189
+ . "$script_dir/plot-desk-root.sh"
188
190
  if [ -z "$wt" ]; then
189
- wt_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
190
- if [ -z "$wt_root" ]; then
191
- wt_root=$(cd "$repo_root/.." && pwd)
192
- wt="$wt_root/plot-wt-$(printf '%s' "$branch" | tr '/' '-')"
193
- else
194
- case "$wt_root" in
195
- /*) : ;;
196
- *) wt_root="$repo_root/$wt_root" ;;
197
- esac
198
- wt="${wt_root%/}/$(printf '%s' "$branch" | tr '/' '-')"
199
- fi
191
+ wt_root=$(plot_desk_root "$(plot_repo_root)") || finish refused no-desk-root
192
+ wt="$wt_root/$(printf '%s' "$branch" | tr '/' '-')"
200
193
  fi
201
194
 
202
195
  if [ "$dry_run" = 1 ]; then
@@ -263,6 +256,7 @@ if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
263
256
  finish refused worktree-busy
264
257
  fi
265
258
  else
259
+ plot_exclude_desk_root "$(plot_repo_root)"
266
260
  if ! git worktree add -q "$wt" "$branch" 2>/dev/null; then
267
261
  if ! git worktree add -q -b "$branch" "$wt" "origin/$branch" 2>/dev/null; then
268
262
  echo "plot-resolve-artifact: cannot create a worktree for $branch at $wt" >&2
package/plot-tmp.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  # plot-tmp.sh — every temp path a Plot script creates, and the process's only
2
- # EXIT/INT/TERM traps. SOURCED, not run.
2
+ # EXIT/INT/TERM/PIPE traps. SOURCED, not run.
3
3
  #
4
4
  # . "$SCRIPT_DIR/plot-tmp.sh"
5
5
  # plot_tmpdir work fleet-ref # $work = $TMPDIR/plot-fleet-ref.XXXXXX (a directory)
@@ -27,8 +27,11 @@
27
27
  # first call would be installed inside that call's substitution and remove the
28
28
  # path when the substitution closed. `PLOT_TMP_LOADED` makes a second source in
29
29
  # the same process a no-op, because a re-run setup would truncate the live
30
- # registry. INT and TERM run the cleanup, clear their own trap and re-raise, so
31
- # the script stops with 130 or 143 rather than running on to exit 0.
30
+ # registry. INT, TERM and PIPE run the cleanup, clear their own trap and
31
+ # re-raise, so the script stops with 130, 143 or 141 rather than running on to
32
+ # exit 0. PIPE is among them because a board server stopped with SIGTERM leaves
33
+ # its running scripts writing to closed pipes, and SIGPIPE's default action
34
+ # skips the EXIT trap.
32
35
  #
33
36
  # A script that sources this must not install its own EXIT, INT or TERM trap:
34
37
  # the last `trap` wins, and that replacement is the defect this file fixes
@@ -42,29 +45,51 @@ PLOT_TMP_LOADED=$$
42
45
  # Fixed at first source: a later `TMPDIR` change in the script does not move it.
43
46
  # A file already at this path belongs to a dead process that had the same pid,
44
47
  # and its `c:` commands are not this process's, so it is replaced, never read.
48
+ # The file itself is created at the end of this file, after the traps.
45
49
  PLOT_TMP_REGISTRY="${TMPDIR:-/tmp}/plot-reg.$$"
46
- rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
47
- : > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
48
50
 
49
51
  # One line per entry, in registration order: `p:<path>` or `c:<command>`.
50
52
  _plot_tmp_register() {
51
53
  printf '%s:%s\n' "$1" "$2" >> "$PLOT_TMP_REGISTRY" 2>/dev/null || true
52
54
  }
53
55
 
56
+ # A SIGNAL INSIDE A CREATION WAITS FOR THE REGISTRATION. bash runs a pending
57
+ # trap as soon as `$(mktemp …)` returns, before the next line registers the
58
+ # path, so a cleanup run there would remove the registry and leave the new path
59
+ # unlisted. While `__plot_tmp_busy` is set, the signal handler records the
60
+ # signal in `__plot_tmp_pending` and returns; `_plot_tmp_create` handles it once
61
+ # the path is registered.
62
+ __plot_tmp_busy=''
63
+ __plot_tmp_pending=''
64
+
65
+ # _plot_tmp_create VAR prefix [-d] — create, register, assign to VAR.
66
+ _plot_tmp_create() {
67
+ local __plot_tmp_new __plot_tmp_rc
68
+ __plot_tmp_busy=1
69
+ if [ "${3:-}" = -d ]; then
70
+ __plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX")
71
+ else
72
+ __plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX")
73
+ fi
74
+ __plot_tmp_rc=$?
75
+ [ "$__plot_tmp_rc" -eq 0 ] && _plot_tmp_register p "$__plot_tmp_new"
76
+ __plot_tmp_busy=''
77
+ if [ -n "$__plot_tmp_pending" ]; then
78
+ # shellcheck disable=SC2086 # "<name> <number>", split on purpose
79
+ _plot_tmp_on_signal $__plot_tmp_pending
80
+ fi
81
+ [ "$__plot_tmp_rc" -eq 0 ] || return 1
82
+ printf -v "$1" '%s' "$__plot_tmp_new"
83
+ }
84
+
54
85
  # plot_tmpdir VAR prefix — create a directory, register it, assign it to VAR.
55
86
  plot_tmpdir() {
56
- local __plot_tmp_new
57
- __plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
58
- _plot_tmp_register p "$__plot_tmp_new"
59
- printf -v "$1" '%s' "$__plot_tmp_new"
87
+ _plot_tmp_create "$1" "$2" -d
60
88
  }
61
89
 
62
90
  # plot_tmpfile VAR prefix — create a file, register it, assign it to VAR.
63
91
  plot_tmpfile() {
64
- local __plot_tmp_new
65
- __plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
66
- _plot_tmp_register p "$__plot_tmp_new"
67
- printf -v "$1" '%s' "$__plot_tmp_new"
92
+ _plot_tmp_create "$1" "$2"
68
93
  }
69
94
 
70
95
  # plot_on_exit command — run `command` (one line) when the process ends.
@@ -75,9 +100,13 @@ plot_on_exit() {
75
100
  # Runs every registered command and removes every registered path, in
76
101
  # registration order, then removes the registry. Runs once: the registry is
77
102
  # gone afterwards, so a second call finds nothing.
103
+ #
104
+ # PIPE is ignored for the cleanup: the process is ending, and a registered
105
+ # command that writes to a closed stdout or stderr must not stop the removals.
78
106
  _plot_tmp_cleanup() {
79
107
  local __plot_tmp_line
80
- [ -f "$PLOT_TMP_REGISTRY" ] || return 0
108
+ [ -n "${__plot_tmp_owned:-}" ] && [ -f "$PLOT_TMP_REGISTRY" ] || return 0
109
+ trap '' PIPE
81
110
  while IFS= read -r __plot_tmp_line; do
82
111
  case $__plot_tmp_line in
83
112
  c:*) eval "${__plot_tmp_line#c:}" ;;
@@ -97,6 +126,10 @@ _plot_tmp_on_exit() {
97
126
  # $1 is the signal name, $2 its number. `kill` re-raises it with the default
98
127
  # disposition; the `exit` is reached only if the shell defers the delivery.
99
128
  _plot_tmp_on_signal() {
129
+ if [ -n "$__plot_tmp_busy" ]; then
130
+ __plot_tmp_pending="$1 $2"
131
+ return 0
132
+ fi
100
133
  trap - EXIT "$1"
101
134
  _plot_tmp_cleanup
102
135
  kill -"$1" "$$"
@@ -106,3 +139,18 @@ _plot_tmp_on_signal() {
106
139
  trap _plot_tmp_on_exit EXIT
107
140
  trap '_plot_tmp_on_signal INT 2' INT
108
141
  trap '_plot_tmp_on_signal TERM 15' TERM
142
+ trap '_plot_tmp_on_signal PIPE 13' PIPE
143
+
144
+ # THE REGISTRY IS CREATED AFTER THE TRAPS, so no signal lands between the file
145
+ # and the handler that removes it. It is replaced under the creation deferral:
146
+ # until `__plot_tmp_owned` is set, a file at this path is a dead process's and
147
+ # the cleanup leaves it unread.
148
+ __plot_tmp_busy=1
149
+ rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
150
+ : > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
151
+ __plot_tmp_owned=1
152
+ __plot_tmp_busy=''
153
+ if [ -n "$__plot_tmp_pending" ]; then
154
+ # shellcheck disable=SC2086 # "<name> <number>", split on purpose
155
+ _plot_tmp_on_signal $__plot_tmp_pending
156
+ fi
@@ -392,6 +392,28 @@ plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers
392
392
  plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
393
393
  }
394
394
 
395
+ # Keep a file the estate writes into every desk out of `git status`.
396
+ #
397
+ # THROUGH THE CLONE'S `info/exclude`, never `.gitignore`: a `.gitignore` rule
398
+ # lives in the branch's own content, so a desk cut from an older branch would
399
+ # not see it, and `info/exclude` is per-repository and shared by every worktree.
400
+ # The directory and the file are CREATED when absent. Measured 2026-10-01
401
+ # (#1130): a clone with no `.git/info/exclude` kept `?? .metadata_never_index`
402
+ # in every free desk, and the AgentMonitor reported each waiting agent as
403
+ # `holds unlanded work`, because the writer skipped a file that did not exist.
404
+ #
405
+ # Best-effort: a desk that cannot take the line still works. Always returns 0.
406
+ plot_desk_exclude() { # $1=worktree $2=the exact line to exclude
407
+ local common excl
408
+ common=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || return 0
409
+ [ -n "$common" ] || return 0
410
+ case "$common" in /*) ;; *) common="$1/$common" ;; esac
411
+ excl="$common/info/exclude"
412
+ mkdir -p "$common/info" 2>/dev/null || return 0
413
+ grep -qxF "$2" "$excl" 2>/dev/null || printf '%s\n' "$2" >> "$excl" 2>/dev/null || true
414
+ return 0
415
+ }
416
+
395
417
  # The same filter, over status output the CALLER already has.
396
418
  #
397
419
  # SPLIT OUT BECAUSE THE STATUS CALL IS THE EXPENSIVE HALF and one caller had