@plot-pm/board 0.14.4 → 0.15.0

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
@@ -19,7 +19,18 @@
19
19
  # than a transient to retry. Adding a host is
20
20
  # an edit to `HOST_DRIVES` and the arms below,
21
21
  # and to nothing in `packages/domain`.
22
- # default-branch print the repo's default branch name
22
+ # default-branch print the repo's default branch name. THE HOST
23
+ # IS ASKED, on both backends, and the local
24
+ # `origin/HEAD` is the fallback where it cannot
25
+ # answer — that ref is a clone-time cache and a
26
+ # default branch moved since then never updates
27
+ # it. Prints "" where neither can answer.
28
+ # account print the signed-in host user, read from the
29
+ # CLI's config file (no API request).
30
+ # `PLOT_BUDGET_ACCOUNT` overrides it. Exit 3
31
+ # where GitHub's `hosts.yml` names no user;
32
+ # exit 4 on Bitbucket, which stores no username
33
+ # for free. Never prints `unknown`.
23
34
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
24
35
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
25
36
  # "draft":true|false,"url":"..."}
@@ -2996,14 +3007,77 @@ case "$op" in
2996
3007
  ;;
2997
3008
 
2998
3009
  default-branch)
3010
+ # THE HOST IS ASKED FIRST, ON BOTH BACKENDS, and `origin/HEAD` is the
3011
+ # fallback rather than the answer. `origin/HEAD` is a LOCAL CACHE WRITTEN AT
3012
+ # CLONE TIME: a default branch changed afterwards never updates it and
3013
+ # nothing in git notices. Reported 2026-09-24 — a clone whose GitHub default
3014
+ # had moved to `develop` kept `origin/HEAD → main`, the board read plans from
3015
+ # `origin/main`, and two of three plans went missing.
3016
+ #
3017
+ # THE BITBUCKET ARM ASKED THE CACHE FIRST UNTIL 2026-09-25, so on Bitbucket
3018
+ # this op answered from the very cache it exists to bypass, the two readings
3019
+ # could never disagree, and the defect was invisible there. Every caller —
3020
+ # `plot-open-pr.sh:73`, `plot-approve.sh:205`, `plot-deliver.sh:218`,
3021
+ # `plot-reap.sh:176`, `plot-release-refs.sh:162`, `/plot-idea:252` — wants
3022
+ # the branch the host calls default, which is what this now answers.
3023
+ #
3024
+ # AND THE FALLBACK WAS DEAD CODE. `git symbolic-ref … | sed … || bb …` takes
3025
+ # the PIPELINE's exit status, which is `sed`'s, and `sed` exits 0 on empty
3026
+ # input — measured 2026-09-25 against a ref that does not exist. So `bb repo
3027
+ # view` had never once been reached, and a Bitbucket clone with no
3028
+ # `origin/HEAD` printed an empty line and exited 0. Each reading is now
3029
+ # tested for a NON-EMPTY value of its own, never for a pipeline's status.
3030
+ _db=""
2999
3031
  if [ "$be" = "github" ]; then
3000
- gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'
3032
+ _db=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)
3001
3033
  else
3002
- # Symbolic ref of origin/HEAD is host-neutral and offline; fall back to
3003
- # the bb API only when the local clone has no origin/HEAD.
3004
- git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \
3005
- || bb repo view --json 2>/dev/null | jq -r '.mainbranch.name'
3034
+ # `--json` IS A CAPABILITY, NOT A GIVEN. Two products share the name `bb`
3035
+ # and craftamap's rejects the flag (see `bb_require_json`) — against it
3036
+ # this call emits `unknown flag: --json`, `jq` reads non-JSON, and the arm
3037
+ # would answer nothing while exiting 0. This call was unguarded while it
3038
+ # was unreachable; asking the host makes it live.
3039
+ bb_require_json
3040
+ _db=$(bb repo view --json 2>/dev/null | jq -r '.mainbranch.name // empty' 2>/dev/null || true)
3006
3041
  fi
3042
+ # `null` is what `jq` prints for an absent field and it is not a branch name.
3043
+ [ "$_db" = "null" ] && _db=""
3044
+ # THE CACHE IS THE FALLBACK: a host that cannot be asked is not a reason to
3045
+ # answer nothing, since every caller above needs a usable branch name and
3046
+ # worked from this reading alone before today.
3047
+ [ -n "$_db" ] || _db=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
3048
+ printf '%s\n' "$_db"
3049
+ ;;
3050
+
3051
+ account)
3052
+ # WHO IS READING, from the same free reading `budget_account` makes. It
3053
+ # costs a file read and no request, which matters because the board asks on
3054
+ # its timer. `PLOT_BUDGET_ACCOUNT` overrides it on every backend.
3055
+ #
3056
+ # THE BITBUCKET ARM IS NOT EXPOSED. `budget_account bitbucket` answers the
3057
+ # remote's owner segment, which is the WORKSPACE: on a team workspace every
3058
+ # contributor gets the same word, and it matches no PR author. Measured
3059
+ # 2026-09-25, neither `bb` stores a username for free. Quatico's keeps only
3060
+ # the Atlassian email, in the Keychain. craftamap's keeps an email in
3061
+ # `configuration.toml`. A Bitbucket PR author carries a nickname, an
3062
+ # account id and a uuid, and never an email. So this backend has no answer,
3063
+ # and exit 4 says so.
3064
+ #
3065
+ # `unknown` IS NEVER PRINTED. It groups budget lines correctly and names
3066
+ # nobody, so an unreadable `hosts.yml` exits 3 with the reason on stderr.
3067
+ _acct=''
3068
+ if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then
3069
+ _acct="$PLOT_BUDGET_ACCOUNT"
3070
+ elif [ "$be" = "github" ]; then
3071
+ _acct="$(budget_account github)"
3072
+ else
3073
+ echo "plot-host.sh: account: $be names no signed-in user without an API request" >&2
3074
+ exit 4
3075
+ fi
3076
+ if [ -z "$_acct" ] || [ "$_acct" = "unknown" ]; then
3077
+ echo "plot-host.sh: account: no user in ${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" >&2
3078
+ exit 3
3079
+ fi
3080
+ printf '%s\n' "$_acct"
3007
3081
  ;;
3008
3082
 
3009
3083
  pr-state)
@@ -3550,7 +3624,7 @@ case "$op" in
3550
3624
  # $jentry == null → the branch has no Jenkins job; `none`.
3551
3625
  # otherwise → the joined colour's `checks`, job named on fail.
3552
3626
  _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 $?
3627
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3554
3628
  pr_list_report_truncation github "$limit" "$state" \
3555
3629
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3556
3630
  printf '%s' "$_gh_raw" \
@@ -3571,6 +3645,7 @@ case "$op" in
3571
3645
  review:(.reviewDecision // ""),
3572
3646
  url:.url,
3573
3647
  updatedAt:(.updatedAt // ""),
3648
+ author:(.author.login // ""),
3574
3649
  failing_checks:(
3575
3650
  if $jentry != null and $jentry.checks == "failing"
3576
3651
  then [$jentry.job]
@@ -3580,7 +3655,7 @@ case "$op" in
3580
3655
  else
3581
3656
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
3582
3657
  _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 $?
3658
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3584
3659
  pr_list_report_truncation github "$limit" "$state" \
3585
3660
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3586
3661
  printf '%s' "$_gh_raw" \
@@ -3603,6 +3678,7 @@ case "$op" in
3603
3678
  review:(.reviewDecision // ""),
3604
3679
  url:.url,
3605
3680
  updatedAt:(.updatedAt // ""),
3681
+ author:(.author.login // ""),
3606
3682
  failing_checks:[
3607
3683
  .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
3608
3684
  | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
@@ -3612,13 +3688,19 @@ case "$op" in
3612
3688
  fi
3613
3689
  else
3614
3690
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3615
- --json number,title,state,headRefName)" || exit $?
3691
+ --json number,title,state,headRefName,author)" || exit $?
3616
3692
  pr_list_report_truncation github "$limit" "$state" \
3617
3693
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3618
3694
  printf '%s' "$_gh_raw" \
3619
- | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName}'
3695
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
3620
3696
  fi
3621
3697
  else
3698
+ # `author` IS THE `nickname`, the one handle field a Bitbucket user
3699
+ # object carries. Measured 2026-09-25 against bb 1.9.0: `author` holds
3700
+ # `display_name`, `nickname`, `account_id` and `uuid` and no username,
3701
+ # and on a Quatico workspace `nickname` equals the display name. GitHub's
3702
+ # arm reads `.author.login`. Both answer `""` where the host omits it.
3703
+ #
3622
3704
  # Bitbucket carries no check rollup through `bb pr list`, and no
3623
3705
  # mergeability verdict either. Rather than guess, --rich reports
3624
3706
  # checks:"unknown" and mergeable:"unknown" — a consumer must render those
@@ -3705,6 +3787,7 @@ case "$op" in
3705
3787
  review:"",
3706
3788
  url:(.links.html.href // ""),
3707
3789
  updatedAt:(.updated_on // ""),
3790
+ author:(.author.nickname // ""),
3708
3791
  failing_checks:(
3709
3792
  if $jentry != null and $jentry.checks == "failing"
3710
3793
  then [$jentry.job]
@@ -3715,13 +3798,13 @@ case "$op" in
3715
3798
  # Bitbucket without Jenkins: checks remain unknown
3716
3799
  PR_LIST_JQ_ARGS=()
3717
3800
  pr_list_states bitbucket "$limit" "$bb_states" \
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:[]}' \
3801
+ '.[] | {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 // ""),author:(.author.nickname // ""),failing_checks:[]}' \
3719
3802
  "${bb_cmd[@]}" || exit $?
3720
3803
  fi
3721
3804
  else
3722
3805
  PR_LIST_JQ_ARGS=()
3723
3806
  pr_list_states bitbucket "$limit" "$bb_states" \
3724
- '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}' \
3807
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,author:(.author.nickname // "")}' \
3725
3808
  "${bb_cmd[@]}" || exit $?
3726
3809
  fi
3727
3810
  fi
package/plot-plan-meta.sh CHANGED
@@ -107,8 +107,9 @@
107
107
  # placeholder
108
108
  # story story slug the plan belongs to (`## Status` `Story:` or
109
109
  # front matter `story:`); "" if absent or a placeholder
110
- # assignee github handle from the `## Approval` `Assignee:` line or
111
- # front matter `assignee:`; "" if absent
110
+ # assignee github handle from front matter `assignee:`, else the
111
+ # `## Approval` `Assignee:` line, else the `## Status`
112
+ # `Assignee:` line; "" if absent or a placeholder
112
113
  # branches branch names, sorted and unique, read from EITHER spelling:
113
114
  # the old `## Branches` section (a LIST ITEM whose first token
114
115
  # is the backtick-quoted name, matching the known prefixes) OR
@@ -415,7 +416,7 @@ function reset_state() {
415
416
  fm_delivered = ""; fm_design = ""
416
417
  fm_rounds = ""
417
418
  canon_state = ""; canon_phase = ""; canon_type = ""
418
- canon_sprint = ""; canon_story = ""; canon_assignee = ""
419
+ canon_sprint = ""; canon_story = ""; canon_assignee = ""; status_assignee = ""
419
420
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
420
421
  canon_delivered = ""; canon_design = ""
421
422
  canon_rounds = ""
@@ -478,6 +479,9 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
478
479
  sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
479
480
  story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
480
481
  assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
482
+ # `## Status` is the section both templates offer; `## Approval` is older and
483
+ # outranks it, so a plan writing both keeps the answer it parsed to before.
484
+ if (fm_assignee == "" && assignee == "") assignee = status_assignee
481
485
  review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
482
486
  impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
483
487
  design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
@@ -823,6 +827,9 @@ section == "status" {
823
827
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
824
828
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
825
829
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
830
+ # Its own slot, not `canon_assignee`: the `## Approval` line outranks this one
831
+ # in `emit_record`, whichever section comes first.
832
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && status_assignee == "") status_assignee = strip_placeholder(val_after_colon($0))
826
833
  # EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
827
834
  #
828
835
  # A plan may hold both a record and an unfilled placeholder for the same
package/plot-pr-merged.sh CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
3
3
  #
4
- # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged` and
5
- # `pr_open`; the file does nothing else on load. That is what makes sourcing it
4
+ # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged`,
5
+ # `pr_open` and `pr_merged_heads`; the file does nothing else on load. That is what makes sourcing it
6
6
  # safe, and it is the same shape — and the same reason — as
7
7
  # `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
8
8
  # because that file parses `$@` and `exit 2`s on an unknown argument at load
@@ -178,3 +178,30 @@ pr_open() {
178
178
  answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
179
179
  case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
180
180
  }
181
+
182
+ # Which commits did the host merge for this branch? One `headRefOid` per merged
183
+ # PR, one per line.
184
+ #
185
+ # A THIRD QUESTION, asked by `plot-reap.sh` only. After a squash merge the
186
+ # host deletes the branch (`delete_branch_on_merge`), `git fetch --prune`
187
+ # drops the remote-tracking ref, and the branch's own commits are then on no
188
+ # remote ref at all. So "which commits did no remote ever hold?" needs the
189
+ # head the host merged: a commit reachable from it was pushed; a commit beyond
190
+ # it exists only on the desk.
191
+ #
192
+ # Returns 1 when the host cannot be asked. The caller reads that as
193
+ # `unknown`, which keeps the desk: here a failure to observe is the case that
194
+ # loses work. It prints nothing and returns 0 when the host answered and no PR
195
+ # merged.
196
+ pr_merged_heads() {
197
+ local br="$1" out
198
+ command -v gh >/dev/null 2>&1 || return 1
199
+ out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt,headRefOid 2>/dev/null) \
200
+ || return 1
201
+ printf '%s' "$out" | node -e '
202
+ let s = "";
203
+ process.stdin.on("data", (d) => { s += d; }).on("end", () => {
204
+ const rows = JSON.parse(s);
205
+ for (const r of rows) if (r.mergedAt && r.headRefOid) console.log(r.headRefOid);
206
+ });' 2>/dev/null || return 1
207
+ }
package/plot-reap.sh CHANGED
@@ -187,6 +187,96 @@ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
187
187
  # newest — and defines `pr_merged` and nothing else on load.
188
188
  . "$(dirname "${BASH_SOURCE[0]}")/plot-pr-merged.sh"
189
189
 
190
+ # Which uncommitted paths count as unlanded work. SOURCED, because the reap
191
+ # reading, the dirty sweep below and `plot-reconcile-scan.sh` section 21 must
192
+ # read one tree the same way; the helper names each excused path.
193
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-desk-dirt.sh"
194
+
195
+ # Is an AGENT still working at a desk? SOURCED from `plot-worker-state.sh`,
196
+ # the ONE classifier `plot-dispatch.sh --stop` also asks. Reading only whether
197
+ # the recorded pid answers `ps` asked about the wrapper shell, which outlives
198
+ # its agent: a desk whose agent had exited read `worker alive` here while
199
+ # `--stop` answered `finished` for the same tree, so it could be neither
200
+ # stopped nor reaped.
201
+ # shellcheck source=plot-worker-state.sh
202
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-worker-state.sh"
203
+
204
+ # The live worker's pid at a desk, or empty. The FIVE process states map to
205
+ # one reading: `running` is live, and `finished`, `failed`, `ended` and `none`
206
+ # are not.
207
+ #
208
+ # `waiting` and `stalled` are DISCARDED BY NAME. They are desk facts — a marker
209
+ # for a person, work on the floor — which `plot_worker_state` reaches only once
210
+ # no agent runs, and they answer what the agent still OWES. That question is
211
+ # the blocked-marker, uncommitted and unpushed readings' below; only the
212
+ # process fact under the two words (no agent is running) crosses into the
213
+ # liveness reading.
214
+ #
215
+ # A word this mapping does not know keeps the desk: it reports the recorded
216
+ # pid, or `unknown`, rather than guessing the process is gone.
217
+ desk_worker_pid() { # $1=worktree → the live worker's pid, or empty
218
+ local row state spid
219
+ row=$(plot_worker_state "$1" "")
220
+ state=$(printf '%s' "$row" | cut -f1)
221
+ spid=$(printf '%s' "$row" | cut -f2)
222
+ case "$state" in
223
+ running) printf '%s' "${spid:-unknown}" ;;
224
+ finished|failed|ended|none) ;;
225
+ waiting|stalled) ;;
226
+ *) printf '%s' "${spid:-unknown}" ;;
227
+ esac
228
+ }
229
+
230
+ # The commits on a desk's HEAD that no remote holds, as short shas, one per
231
+ # line. Returns 1 when they cannot be counted, which the rule reads as
232
+ # `unknown` and refuses on.
233
+ #
234
+ # NOT `@{upstream}..HEAD`. The host deletes a branch when its PR merges, and
235
+ # the reaper only removes desks whose PR merged, so after `git fetch --prune`
236
+ # every desk it serves has lost its upstream — and an absent upstream counts as
237
+ # nothing. The reading is `HEAD --not --remotes` instead: what no remote-tracking
238
+ # ref holds. After a squash merge and a pruned ref, the branch's own commits
239
+ # are on no remote ref either, so the head the host MERGED is excluded too
240
+ # (`pr_merged_heads`): a commit beyond it was never pushed.
241
+ desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
242
+ local wt="$1" br="$2" merge="$3" list heads h
243
+ local -a excl=()
244
+ list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes 2>/dev/null) || return 1
245
+ [ -n "$list" ] || return 0
246
+ if [ -n "$br" ] && [ "$merge" = merged ]; then
247
+ heads=$(pr_merged_heads "$br") || return 1
248
+ for h in $heads; do
249
+ git -C "$wt" cat-file -e "$h^{commit}" 2>/dev/null && excl+=("$h")
250
+ done
251
+ if [ "${#excl[@]}" -gt 0 ]; then
252
+ list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes "${excl[@]}" 2>/dev/null) \
253
+ || return 1
254
+ else
255
+ # NO MERGED HEAD THIS DESK CONTAINS, and the host still said merged. The
256
+ # subtraction above cannot run, and without it `--not --remotes` reports
257
+ # EVERY commit the branch ever had — so the desk would be held forever
258
+ # for having done the work that merged.
259
+ #
260
+ # Two ways to reach here, both normal. A SQUASH merge rewrites the
261
+ # commits, so the head the host names exists nowhere in this history —
262
+ # the same property that makes `plot-pr-merged.sh` read `mergedAt` and
263
+ # never ancestry, measured here as ancestry clearing 1 of 29 finished
264
+ # trees against the host's 28. And a host answer that carries no head at
265
+ # all leaves nothing to subtract.
266
+ #
267
+ # The host's answer decides: it said this branch's work landed, and no
268
+ # reading taken from refs may overrule it. Answer empty.
269
+ #
270
+ # THE GUARD KEEPS ITS WHOLE POPULATION. It protects a desk whose agent
271
+ # finished and whose work has NOT merged, which is where work is lost.
272
+ # The merged desk that holds a commit the PR did not carry is still held,
273
+ # by the subtraction above, whenever the merged head is one this desk has.
274
+ list=""
275
+ fi
276
+ fi
277
+ printf '%s\n' "$list"
278
+ }
279
+
190
280
  # Where the registry lives, resolved through `plot-config.sh` — the SAME key and
191
281
  # default the board's reader uses (`resolveManifestDir` in `registry.ts` shells
192
282
  # out to exactly this). Two implementations of "where is the registry" is how
@@ -501,25 +591,21 @@ while IFS=$'\037' read -r wt br prunable; do
501
591
 
502
592
  # The process table: the live worker's pid, or empty. Read but not judged —
503
593
  # an empty pid file is not a live process, and which of those two it is is
504
- # the rule's to say.
505
- pid=""
506
- if [ -f "$wt/.plot-worker.pid" ]; then
507
- p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
508
- if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then pid="$p"; fi
509
- fi
594
+ # the rule's to say. "Live" means an agent runs under the recorded pid, the
595
+ # answer `--stop` reads; a wrapper whose agent exited is not live.
596
+ pid=$(desk_worker_pid "$wt")
510
597
 
511
598
  # The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
512
599
  #
513
- # The tiny-garden pulse is excused because every board suite rewrites it — a
514
- # worker that did nothing but run the tests would otherwise never be
515
- # reapable. Any OTHER dirty path is still reported, which keeps this an
516
- # exception rather than a hole. It is filtered HERE, in the reading, because
517
- # it is a fact about this repository's fixtures and not about whether a
518
- # worktree may go.
600
+ # `desk_dirt` excuses the paths the estate writes itself — the tiny-garden
601
+ # pulse and a root `PLOT-CORRECTION.md` — and reports every OTHER dirty path,
602
+ # which keeps this an exception rather than a hole. It is filtered HERE, in
603
+ # the reading, because those are facts about files this estate writes and
604
+ # not about whether a worktree may go. The filter runs before `head`, so a
605
+ # correction beside a real file names the real file.
519
606
  marker=false
520
607
  ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
521
- dirty=$(git -C "$wt" status --porcelain 2>/dev/null \
522
- | grep -v 'tiny-garden/\.plot/state' | head -1)
608
+ dirty=$(desk_dirt "$wt" | head -1)
523
609
 
524
610
  # The host: whether ANY PR for this branch merged.
525
611
  #
@@ -560,6 +646,12 @@ while IFS=$'\037' read -r wt br prunable; do
560
646
  merge=merged; why="detached, nothing to land"
561
647
  fi
562
648
 
649
+ # The desk again: commits only this checkout holds. Taken AFTER the merge
650
+ # reading, because the head the host merged is what separates a pushed
651
+ # commit from an unpushed one once the remote ref is gone. Lines, or the
652
+ # word `unknown` when they could not be counted.
653
+ if unpushed=$(desk_unpushed "$wt" "$short" "$merge"); then :; else unpushed=unknown; fi
654
+
563
655
  # THE DECISION. One call, and the script holds no `if` about whether a
564
656
  # worktree may go — only about what to do with the answer.
565
657
  #
@@ -581,7 +673,7 @@ while IFS=$'\037' read -r wt br prunable; do
581
673
  # the tree and says why. Silence is never permission, on this path either.
582
674
  verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
583
675
  PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
584
- PLOT_RULE="$RULE" \
676
+ PLOT_UNPUSHED="$unpushed" PLOT_RULE="$RULE" \
585
677
  node --input-type=module - <<'NODE_EOF' 2>/dev/null
586
678
  // Imported from an ABSOLUTE path derived from this script, never from the
587
679
  // cwd. The reaper runs with its cwd wherever the operator invoked it and the
@@ -603,6 +695,9 @@ const problem = firstReapRefusal({
603
695
  dirtyPath: process.env.PLOT_DIRTY,
604
696
  blockedMarker: process.env.PLOT_MARKER === "true",
605
697
  merge: process.env.PLOT_MERGE,
698
+ unpushed: process.env.PLOT_UNPUSHED === "unknown"
699
+ ? "unknown"
700
+ : process.env.PLOT_UNPUSHED.split("\n").filter((l) => l !== ""),
606
701
  });
607
702
 
608
703
  // `reap` when nothing refused; otherwise the refusal and its reading, which
@@ -622,6 +717,7 @@ NODE_EOF
622
717
  live-worker) reason="worker alive (pid $detail)" ;;
623
718
  blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
624
719
  uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
720
+ unpushed-commits) reason="unpushed commits: ${detail:0:40}" ;;
625
721
  on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
626
722
  no-merged-pr) reason="unlanded work — no merged PR" ;;
627
723
  *) reason="rule could not be asked — keeping" ;;
@@ -1032,15 +1128,12 @@ while IFS=$'\t' read -r wt br; do
1032
1128
  [ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
1033
1129
  dshort=${br#refs/heads/}
1034
1130
 
1035
- dcount=$(git -C "$wt" status --porcelain 2>/dev/null \
1036
- | grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
1131
+ dcount=$(desk_dirt "$wt" | wc -l | tr -d ' ')
1037
1132
  [ "${dcount:-0}" -gt 0 ] || continue
1038
1133
 
1039
- dpid=""
1040
- if [ -f "$wt/.plot-worker.pid" ]; then
1041
- p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
1042
- if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
1043
- fi
1134
+ # The same liveness reading the reap loop takes, so a desk the reaper keeps
1135
+ # for a live worker and one the sweep names as owned are the same desks.
1136
+ dpid=$(desk_worker_pid "$wt")
1044
1137
 
1045
1138
  dmanifest=""
1046
1139
  if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi