@plot-pm/board 0.16.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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",
@@ -31,6 +31,7 @@
31
31
  "plot-config.sh",
32
32
  "plot-default-branch.sh",
33
33
  "plot-deliver.sh",
34
+ "plot-desk-root.sh",
34
35
  "plot-dispatch.sh",
35
36
  "plot-fleet-scan.sh",
36
37
  "plot-host.sh",
package/plot-approve.sh CHANGED
@@ -128,7 +128,13 @@ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run thi
128
128
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
129
129
 
130
130
  repo_root=$(git rev-parse --show-toplevel)
131
- wt_root=$(cd "$repo_root/.." && pwd)
131
+ # The booking worktree goes under the desk root of the MAIN checkout, never of
132
+ # this tree: inside a desk `--show-toplevel` answers the desk, and the default
133
+ # `.worktrees` would resolve beneath it. No fallback — `plot-desk-root.sh`.
134
+ # shellcheck source=plot-desk-root.sh
135
+ . "$script_dir/plot-desk-root.sh"
136
+ main_root=$(plot_repo_root)
137
+ wt_root=$(plot_desk_root "$main_root") || die "cannot resolve where the booking worktree goes (see above)"
132
138
 
133
139
  PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
134
140
  ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
@@ -735,6 +741,9 @@ else
735
741
 
736
742
  bookbr="plot/approve-$slug"
737
743
  tmpwt="$wt_root/.plot-approve-$slug.$$"
744
+ # `git worktree add` creates the desk root when it is missing, so exclude it
745
+ # first: a booking run must not leave `.worktrees/` untracked in `git status`.
746
+ plot_exclude_desk_root "$main_root"
738
747
  # -B: a leftover branch from an earlier failed run must not block this one.
739
748
  # It is disposable by construction — created here, pushed, deleted.
740
749
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
package/plot-config.sh CHANGED
@@ -27,12 +27,7 @@
27
27
  # Project board | Branch prefixes | Plan directory | Active index |
28
28
  # Delivered index | Sprint directory | Story directory | Story index |
29
29
  # Plan template | Worker prompt template | Main branch | Board command
30
- # Worktree root where /plot-dispatch puts its worktrees. Read by
31
- # plot-dispatch.sh; default is the repo's PARENT, which
32
- # scatters `plot-wt-*` beside the checkout. An absolute
33
- # path is taken as given; a relative one resolves against
34
- # the repo root, so `.worktrees` gathers them inside it.
35
- # The default is kept for repos that never set it.
30
+ # Worktree root the desk root; see its entry under the agent keys below.
36
31
  # Worker bound seconds a single prompt run may take in the worker loop
37
32
  # before it is ended and the worker exits (no hop). Read by
38
33
  # plot-worker-loop.sh; default 3600 (~1h), `0` disables it.
@@ -99,14 +94,15 @@
99
94
  # project-owned and reviewed like this one.
100
95
  # Absent or empty = no change, so an adopting project that
101
96
  # sets nothing behaves exactly as today.
102
- # Worktree root where /plot-dispatch creates fleet worktrees. A relative
103
- # value resolves against the repo root, an absolute one is
104
- # taken as given. Absent = the default `repo_root/..` with
105
- # the `plot-wt-` prefix — today's behaviour, so no existing
106
- # checkout moves. Under a dedicated root the prefix is
107
- # dropped: the directory already says these are Plot's. Read
108
- # only by the CREATION path; every "which worktree holds this
109
- # branch" read asks `git worktree list` instead.
97
+ # Worktree root the desk root: where /plot-dispatch creates fleet
98
+ # worktrees and the board writes its action records. A
99
+ # relative value resolves against the MAIN checkout, an
100
+ # absolute one is taken as given. Absent or empty =
101
+ # `<repo>/.worktrees`. The rule is `deskRoot`, asked through
102
+ # `plot-desk-root.sh`; no script resolves it itself. Desks
103
+ # carry no prefix; `plot-wt-*` desks an older dispatch made
104
+ # beside the repo stay there. Every "which worktree holds
105
+ # this branch" read asks `git worktree list` instead.
110
106
  # Agent-runner keys (optional; Plot hardcodes no agent tooling, Principle 5):
111
107
  # Worker command how /plot-dispatch runs an agent headless on a worktree.
112
108
  # /plot-init writes `PLOT_UNATTENDED=1 plot-worker-loop.sh`;
package/plot-deliver.sh CHANGED
@@ -81,7 +81,13 @@ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run thi
81
81
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
82
82
 
83
83
  repo_root=$(git rev-parse --show-toplevel)
84
- wt_root=$(cd "$repo_root/.." && pwd)
84
+ # The booking worktree goes under the desk root of the MAIN checkout, never of
85
+ # this tree: inside a desk `--show-toplevel` answers the desk, and the default
86
+ # `.worktrees` would resolve beneath it. No fallback — `plot-desk-root.sh`.
87
+ # shellcheck source=plot-desk-root.sh
88
+ . "$script_dir/plot-desk-root.sh"
89
+ main_root=$(plot_repo_root)
90
+ wt_root=$(plot_desk_root "$main_root") || die "cannot resolve where the booking worktree goes (see above)"
85
91
 
86
92
  PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
87
93
  ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
@@ -635,6 +641,9 @@ git fetch -q origin "$MAIN" 2>/dev/null
635
641
 
636
642
  bookbr="plot/deliver-$slug"
637
643
  tmpwt="$wt_root/.plot-deliver-$slug.$$"
644
+ # `git worktree add` creates the desk root when it is missing, so exclude it
645
+ # first: a booking run must not leave `.worktrees/` untracked in `git status`.
646
+ plot_exclude_desk_root "$main_root"
638
647
  # -B: a leftover branch from an earlier failed run must not block this one.
639
648
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
640
649
  || die "could not prepare a booking worktree at $tmpwt.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # The ONE answer to "where do desks and action records go?" — sourced, not run.
3
+ #
4
+ # Every script that resolved this privately asks here instead. Nine sites
5
+ # computed the rule on 2026-10-01 and two of them already answered `.worktrees`
6
+ # while seven answered the checkout's parent, so one of the two populations was
7
+ # always writing somewhere the other did not read.
8
+ #
9
+ # The rule itself is `deskRoot` in the domain, asked through
10
+ # `board/plot-desk-root.mjs`, per *A Shell Script Asks The Domain*: every caller
11
+ # runs once per operator command, which is the cost rule's permitted case.
12
+ #
13
+ # NO CALLER KEEPS A FALLBACK DEFAULT. When the bundle cannot answer — no `node`,
14
+ # a missing file — the caller stops with the reason. A silent fallback is a
15
+ # second default, and two defaults are the defect this removes.
16
+ #
17
+ # plot_repo_root → the MAIN checkout, from anywhere
18
+ # plot_desk_root [repo [configured]] → the desk root; exit 3 when unaskable
19
+ # plot_exclude_desk_root [repo] → keep it out of `git status`; never fails
20
+ #
21
+ # shellcheck shell=bash
22
+
23
+ # The MAIN checkout, from a desk as readily as from the checkout itself.
24
+ #
25
+ # `--show-toplevel` answers the DESK inside a linked worktree, so with
26
+ # `.worktrees` as the default it would resolve `<desk>/.worktrees` and place
27
+ # every tree under a root nothing else reads. `plot-reap.sh:440-447` recorded
28
+ # that failure on 2026-09-10, when every tree read as unplaceable.
29
+ #
30
+ # The parent of `--git-common-dir` is the main checkout from anywhere, because
31
+ # every linked worktree shares that one directory. It is asked FIRST and
32
+ # `--show-toplevel` is the fallback, since the common dir is also correct in a
33
+ # non-worktree checkout: there `.git` is a real directory in the root.
34
+ #
35
+ # PHYSICAL, through `pwd -P`: `git worktree list` and `--show-toplevel` print
36
+ # resolved paths, and a desk root composed from a logical one (`/tmp` against
37
+ # `/private/tmp` on macOS) is a prefix no worktree path ever starts with.
38
+ plot_repo_root() {
39
+ local common root=''
40
+ common=$(git rev-parse --git-common-dir 2>/dev/null) && [ -n "$common" ] && {
41
+ common=$(cd -- "$common" 2>/dev/null && pwd -P) || common=''
42
+ [ -n "$common" ] && root=$(dirname -- "$common")
43
+ }
44
+ [ -n "$root" ] || root=$(git rev-parse --show-toplevel 2>/dev/null) || root=$(pwd)
45
+ printf '%s\n' "$root"
46
+ }
47
+
48
+ # Resolved ONCE at source time, the way `plot-pr-merged.sh:105` resolves its
49
+ # own bundle. Computing it inside a function reads `BASH_SOURCE[0]` at call
50
+ # time, which is the caller's file once the function has been exported.
51
+ _plot_desk_root_mjs="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/board/plot-desk-root.mjs"
52
+ _plot_desk_root_config="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/plot-config.sh"
53
+
54
+ # The desk root for a repository, or exit 3 with the reason on stderr.
55
+ #
56
+ # A second argument is a value the caller was handed in place of the key — a
57
+ # `--worktrees DIR` flag — and goes through the same rule; without one the
58
+ # `Worktree root` key is read.
59
+ #
60
+ # READ THE EXIT CODE. A caller that treats an empty answer as a location would
61
+ # compose paths against the filesystem root.
62
+ plot_desk_root() {
63
+ local repo="${1:-}" bundle="$_plot_desk_root_mjs" configured answer
64
+ [ -n "$repo" ] || repo=$(plot_repo_root)
65
+ if [ ! -f "$bundle" ]; then
66
+ printf 'plot: cannot resolve the desk root: %s is missing\n' "$bundle" >&2
67
+ return 3
68
+ fi
69
+ if [ "$#" -ge 2 ]; then
70
+ configured="$2"
71
+ else
72
+ # Read from the TARGET repository, never the ambient one: `plot-config.sh`
73
+ # walks up from its cwd, so a caller asking about another checkout would
74
+ # otherwise get this one's key.
75
+ # A read that fails is a refusal, never the absent row: reading it as empty
76
+ # would answer the default for a repository that configured another root.
77
+ configured=$(cd -- "$repo" 2>/dev/null && bash "$_plot_desk_root_config" get "Worktree root" "" 2>/dev/null) || {
78
+ printf 'plot: cannot resolve the desk root: the Worktree root key in %s could not be read\n' "$repo" >&2
79
+ return 3
80
+ }
81
+ fi
82
+ answer=$(node "$bundle" "$repo" "$configured" 2>&1) || {
83
+ printf 'plot: cannot resolve the desk root: %s\n' "$answer" >&2
84
+ return 3
85
+ }
86
+ [ -n "$answer" ] || {
87
+ printf 'plot: the desk root rule answered nothing for %s\n' "$repo" >&2
88
+ return 3
89
+ }
90
+ printf '%s\n' "$answer"
91
+ }
92
+
93
+ # Keep the desk root out of `git status`, when it lies inside the repository.
94
+ #
95
+ # The line goes into the COMMON git directory's `info/exclude`: git reads that
96
+ # file from the common directory only, so a linked worktree's private gitdir is
97
+ # the wrong place and a desk writing there would exclude nothing. The write is
98
+ # idempotent, and `info/exclude` is never committed.
99
+ #
100
+ # Called by whoever CREATES the directory. Best-effort and always exit 0: a
101
+ # failure leaves untracked files in a listing, which is untidy, while refusing
102
+ # to create a desk over it would stop the work.
103
+ plot_exclude_desk_root() {
104
+ local repo="${1:-}" bundle="$_plot_desk_root_mjs" line common exclude configured
105
+ [ -n "$repo" ] || repo=$(plot_repo_root)
106
+ [ -f "$bundle" ] || return 0
107
+ configured=$(cd -- "$repo" 2>/dev/null && bash "$_plot_desk_root_config" get "Worktree root" "" 2>/dev/null) || configured=''
108
+ line=$(node "$bundle" --exclude-line "$repo" "$configured" 2>/dev/null) || return 0
109
+ # Empty means the root lies outside the repository, which needs no line.
110
+ [ -n "$line" ] || return 0
111
+ # Already ignored, by a `.gitignore` line /plot-init wrote or by an earlier
112
+ # run. A second rule for a path already ignored is noise in a file people read.
113
+ ( cd -- "$repo" 2>/dev/null && git check-ignore -q ".$line" ) && return 0
114
+ common=$(cd -- "$repo" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null) || return 0
115
+ [ -n "$common" ] || return 0
116
+ case "$common" in /*) ;; *) common="$repo/$common" ;; esac
117
+ exclude="$common/info/exclude"
118
+ if [ -f "$exclude" ] && grep -qxF "$line" "$exclude" 2>/dev/null; then return 0; fi
119
+ mkdir -p "$common/info" 2>/dev/null || return 0
120
+ # A file not ending in a newline would otherwise join this line to its last.
121
+ if [ -s "$exclude" ] && [ "$(tail -c 1 "$exclude" 2>/dev/null)" != "" ]; then
122
+ printf '\n%s\n' "$line" >> "$exclude" 2>/dev/null || true
123
+ else
124
+ printf '%s\n' "$line" >> "$exclude" 2>/dev/null || true
125
+ fi
126
+ return 0
127
+ }
package/plot-dispatch.sh CHANGED
@@ -219,40 +219,37 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
219
219
  # WHERE THE WORKTREES LIVE, and by what name
220
220
  # ---------------------------------------------------------------------------
221
221
  #
222
- # Two facts, resolved together because the second is a PROPERTY OF THE FIRST:
223
- # the root directory the worktrees sit in, and the prefix their directory names
224
- # carry. `plot-wt-` exists to make Plot's worktrees identifiable AMONG UNRELATED
225
- # directories — it is a workaround for sharing a parent with other projects.
226
- # Under a dedicated `Worktree root:` the directory already says what they are,
227
- # so the prefix answers a question nobody is asking and is dropped. The legacy
228
- # default keeps it, where it is still doing its job. Two conventions coexist
229
- # permanently, and that is the intended outcome, not a transition cost.
230
- #
231
- # `Worktree root:` absent → repo_root/.. , prefix `plot-wt-` (today's behaviour)
232
- # relative value → repo_root/<value> , NO prefix
233
- # absolute value → <value> as given , NO prefix
222
+ # Two facts: the root directory the worktrees sit in, and the prefix their
223
+ # directory names carry. The root is the desk root, `deskRoot`'s answer asked
224
+ # through `plot-desk-root.sh`:
225
+ #
226
+ # `Worktree root:` absent or empty → <main checkout>/.worktrees
227
+ # relative value → <main checkout>/<value>
228
+ # absolute value → <value> as given
229
+ #
230
+ # The prefix is always empty. `plot-wt-` made Plot's worktrees identifiable
231
+ # among unrelated directories in the checkout's parent; the desk root is a
232
+ # directory Plot owns, so the directory already says what they are. Desks a
233
+ # dispatch created under the old default keep their `plot-wt-` names and their
234
+ # place: every read of "which worktree holds this branch" asks `git worktree
235
+ # list`, and `plot-reap.sh` still recognises the legacy path.
236
+ #
237
+ # The root is the MAIN checkout's, wherever this runs: inside a desk,
238
+ # `--show-toplevel` answers the desk and `.worktrees` would resolve beneath it.
239
+ # There is NO FALLBACK: when the rule cannot be asked, dispatch stops with the
240
+ # reason rather than compute a second default.
234
241
  #
235
242
  # THIS FUNCTION ONLY COMPOSES A ROOT AND A PREFIX. It is the CREATION side. Every
236
243
  # read of "which worktree holds this branch" asks `git worktree list` instead —
237
244
  # see THE HELD-BRANCH GATE. A second naming convention gives path-guessing a
238
245
  # second way to be wrong, so path-guessing is confined to creation alone.
239
- resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
240
- local rr="$1" configured
241
- configured=$("$script_dir/plot-config.sh" get "Worktree root" "")
242
- if [ -z "$configured" ]; then
243
- # The legacy default: beside the repo, prefixed. No existing checkout moves.
244
- wt_root=$(cd "$rr/.." && pwd)
245
- wt_prefix="plot-wt-"
246
- return
247
- fi
248
- case "$configured" in
249
- /*) wt_root="$configured" ;; # absolute: taken as given
250
- *) wt_root="$rr/$configured" ;; # relative: against the repo root
251
- esac
252
- # Normalise away a trailing slash so composed paths never double it. The
253
- # directory may not exist yet (created on first dispatch), so this is pure
254
- # string work, not a `cd`.
255
- wt_root="${wt_root%/}"
246
+ # shellcheck source=plot-desk-root.sh
247
+ . "$script_dir/plot-desk-root.sh"
248
+ resolve_wt_root() { # sets globals wt_root, wt_prefix; exits 3 when unaskable
249
+ wt_root=$(plot_desk_root "$(plot_repo_root)") || {
250
+ echo "plot-dispatch: cannot resolve where the worktrees go — nothing was dispatched." >&2
251
+ exit 3
252
+ }
256
253
  wt_prefix=""
257
254
  }
258
255
 
@@ -1645,9 +1642,13 @@ charter_file_for() { # $1 = agent name → prints the path it would read
1645
1642
  # inspectable and stoppable even if the plan was since delivered or rejected.
1646
1643
  # Refusing to show a running worker because of a phase change would strand it.
1647
1644
  repo_root_early=$(git rev-parse --show-toplevel)
1648
- resolve_wt_root "$repo_root_early"
1649
- wt_root_early="$wt_root"
1650
- wt_prefix_early="$wt_prefix"
1645
+ # Only the two verbs that enumerate desks ask for the root here; every other
1646
+ # path asks where it composes one, so its own refusals answer first.
1647
+ if [ "$mode" = status ] || [ "$mode" = stop ]; then
1648
+ resolve_wt_root
1649
+ wt_root_early="$wt_root"
1650
+ wt_prefix_early="$wt_prefix"
1651
+ fi
1651
1652
 
1652
1653
  # States: "running <pid>" | "finished <pid>" | "waiting <pid> (answer it)"
1653
1654
  # | "stalled <pid> (work unfinished)" | "failed <pid> (exit N)"
@@ -2160,7 +2161,6 @@ if [ "$mode" = "start" ]; then
2160
2161
  # about any plan, so there is no plan whose phase could refuse it. A gate on a
2161
2162
  # slug this verb never takes would refuse every call.
2162
2163
  repo_root="$repo_root_early"
2163
- resolve_wt_root "$repo_root"
2164
2164
 
2165
2165
  # THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
2166
2166
  # config key, then origin's own HEAD, then `main`. Resolved here because the
@@ -2370,6 +2370,9 @@ EOF
2370
2370
  # `wait_for_work` skips the outlook scan for an agent that holds none.
2371
2371
  slug=""
2372
2372
 
2373
+ # Where the desks go, asked only now: every refusal above answers first, and
2374
+ # a broken runtime is named by the start-command check rather than here.
2375
+ resolve_wt_root
2373
2376
  start_made=0
2374
2377
  start_i=0
2375
2378
  while [ "$start_i" -lt "$start_n" ]; do
@@ -2423,6 +2426,9 @@ EOF
2423
2426
  continue
2424
2427
  fi
2425
2428
  mkdir -p "$wt_root" 2>/dev/null || true
2429
+ # The desk root lies inside the repository by default, so keep it out of
2430
+ # `git status` the moment it exists.
2431
+ plot_exclude_desk_root "$(plot_repo_root)"
2426
2432
  if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
2427
2433
  # NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
2428
2434
  # with no remote has no `origin/<main>`; the local one is the same commit
@@ -2438,10 +2444,7 @@ EOF
2438
2444
  # the marker is ignored via `info/exclude` rather than `.gitignore`, because
2439
2445
  # an untracked file in a desk reads as unlanded work to
2440
2446
  # `plot-worker-state.sh` and would make every free agent look stalled.
2441
- _excl="$(git -C "$start_wt" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
2442
- if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
2443
- printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
2444
- fi
2447
+ plot_desk_exclude "$start_wt" '.metadata_never_index'
2445
2448
  : > "$start_wt/.metadata_never_index" 2>/dev/null || true
2446
2449
 
2447
2450
  echo " desk $start_wt (detached at origin/$start_main)"
@@ -2484,8 +2487,9 @@ if [ "$mode" = "migrate" ]; then
2484
2487
  configured_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
2485
2488
  if [ -z "$configured_root" ]; then
2486
2489
  echo "plot-dispatch --migrate: no 'Worktree root:' configured — nothing to migrate."
2487
- echo " When Worktree root is absent, worktrees live beside the repo (plot-wt-*)."
2488
- echo " To migrate, first add a 'Worktree root:' key to ## Plot Config."
2490
+ echo " When Worktree root is absent, new worktrees go to <repo>/.worktrees;"
2491
+ echo " desks an older dispatch made beside the repo (plot-wt-*) stay there."
2492
+ echo " To move those, first add a 'Worktree root:' key to ## Plot Config."
2489
2493
  exit 0
2490
2494
  fi
2491
2495
 
@@ -3042,13 +3046,12 @@ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipp
3042
3046
  }
3043
3047
 
3044
3048
  # Where the worktrees live and what their names carry — see resolve_wt_root.
3045
- # The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
3046
- # key relocates them (and drops the prefix, which was only earning its keep
3047
- # among unrelated sibling directories). A nested root is made invisible to
3048
- # `git status` and the marker grep by a `.gitignore` line, not by living
3049
- # outside the repo.
3049
+ # The default is `<main checkout>/.worktrees` with no prefix; a `Worktree root:`
3050
+ # key relocates them. A root inside the repository is kept out of `git status`
3051
+ # by the `info/exclude` line `plot_exclude_desk_root` writes when a desk is
3052
+ # created.
3050
3053
  repo_root=$(git rev-parse --show-toplevel)
3051
- resolve_wt_root "$repo_root"
3054
+ resolve_wt_root
3052
3055
 
3053
3056
  n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
3054
3057
  n_brief_asked=0
@@ -3289,6 +3292,9 @@ write_started_record() { # $@ = branches
3289
3292
  # stale tip and the push would be a guaranteed non-fast-forward.
3290
3293
  git fetch -q origin "$MAIN" 2>/dev/null
3291
3294
 
3295
+ # `git worktree add` creates the desk root when it is missing; exclude it
3296
+ # first so the booking leaves no untracked `.worktrees/` behind.
3297
+ plot_exclude_desk_root "$(plot_repo_root)"
3292
3298
  # -B: a leftover branch from an earlier failed booking must not block this
3293
3299
  # one. It is disposable by construction — created here, pushed, deleted.
3294
3300
  if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
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