@plot-pm/board 0.16.3 → 0.18.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.
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: fleet pulse — deterministic extractor for wave/claim state.
3
- # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
3
+ # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [--slice-names] [<slug>]
4
+ # --slice-names print every slice a `waits:` name may wait on, one per line,
5
+ # and exit: the estate's non-deferred slices of non-terminal plans,
6
+ # whatever slug is given. No fetch, no host call.
4
7
  # --no-fetch skip `git fetch`
5
8
  # --offline same (no fetch) — used for cheap, ambient pulses.
6
9
  # The fetch also PRUNES remote-tracking refs, so skipping it
@@ -76,11 +79,12 @@
76
79
  # Branch states — the word each BRANCH carries, distinct from the wave verdicts
77
80
  # above. `open`, `wip`, `merged`, `claimed`, `deferred` and `unknown` are read
78
81
  # from git and the host. Two more are read from the plan's `waits:` annotation:
79
- # waiting the branch names a prerequisite branch that has not merged. A
82
+ # waiting the branch names a prerequisite branch that has not merged —
83
+ # including a slice some plan names that nobody has started. A
80
84
  # wait with an end: it clears when that branch lands, and the
81
85
  # fleet payload carries `waits_on` so a reader sees on WHAT.
82
86
  # blocked the branch names a prerequisite the host has never seen a PR
83
- # for — a typo, or a branch nobody created. A defect in the plan
87
+ # for and no plan names as a slice — a typo. A defect in the plan
84
88
  # estate, not progress, which is why it is a separate word: the
85
89
  # first resolves by waiting, the second by editing the plan.
86
90
  # THE SAME WORD AS THE WAVE VERDICT, IN A DIFFERENT VOCABULARY.
@@ -261,6 +265,7 @@ do_fetch=1
261
265
  next_only=0
262
266
  list_all=0
263
267
  why_nothing=0
268
+ slice_names=0
264
269
  loose=0
265
270
  log_pulse=0
266
271
  as_json=0
@@ -295,6 +300,7 @@ while [ $# -gt 0 ]; do
295
300
  # about the SAME plans `--next` was silent over — a terminal plan admitted
296
301
  # here would answer `not-yet` about work somebody decided was not needed.
297
302
  --why-nothing) next_only=1; why_nothing=1 ;;
303
+ --slice-names) slice_names=1; do_fetch=0 ;;
298
304
  # `--json` ASSEMBLES BUT DOES NOT RECORD, and the two flags differ here for
299
305
  # a reason. `--stream` is what the BOARD spawns (`fleet.ts:2694`) and
300
306
  # `--log-pulse` is what `/plot-pulse` passes: both produce a pulse somebody
@@ -1535,10 +1541,7 @@ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
1535
1541
  # backend, a host returning 503 all afternoon — must not manufacture the state
1536
1542
  # that tells a reader to stop looking. It falls through to the local signals,
1537
1543
  # so a branch with work on the floor reads `stalled`: go and look. That is the
1538
- # safe direction for an answer nobody could verify.
1539
- reached_review() { # $1=branch → 0 when an open or merged PR exists
1540
- case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
1541
- }
1544
+ # safe direction for an answer nobody could verify. `worker_of` asks it.
1542
1545
 
1543
1546
  # ---------------------------------------------------------------------------
1544
1547
  # A SLICE THAT WAITS ON ANOTHER PLAN'S BRANCH
@@ -1571,9 +1574,9 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
1571
1574
  # the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
1572
1575
  # refs, and why this reads the same source.
1573
1576
  #
1574
- # WHAT THE HOST SAID, AND NOT WHAT IT MEANS. This function answered
1577
+ # WHAT THE HOST SAID, AND NOT WHAT IT MEANS. The prerequisite reading answered
1575
1578
  # `waiting` / `blocked` / `""` until the derivation moved: the three answers and
1576
- # the reason `NONE` is the only one that means `blocked` are `waitVerdict` in
1579
+ # why only `NONE` on a name outside `SLICE_NAMES` means `blocked` are `waitVerdict` in
1577
1580
  # `packages/domain/src/rules/branch-state.ts`, with a test per case. What stays
1578
1581
  # here is the READING and the cost argument above it, which is a fact about
1579
1582
  # this script's host budget rather than about what a wait means.
@@ -1582,9 +1585,8 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
1582
1585
  # may legitimately omit: its plan may be delivered and its ref gone. The bound
1583
1586
  # is the same one PR #216 set — ABSENT branches, not all branches — and the
1584
1587
  # cache above keeps it at one call per run.
1585
- waits_pr_state() { # $1=prerequisite branch → OPEN|MERGED|CLOSED|NONE|-
1586
- host_pr_state "$1" --ask
1587
- }
1588
+ # `host_pr_state "$name" --ask` → OPEN|MERGED|CLOSED|NONE|-, called once per
1589
+ # prerequisite name in the refill below.
1588
1590
 
1589
1591
  # Modification time of a path, in epoch seconds, following symlinks — or "" when
1590
1592
  # it cannot be read.
@@ -2140,10 +2142,10 @@ worker_of() { # $1=branch → "state\tpid\texit"
2140
2142
  #
2141
2143
  # `$st` IS NOT THIS FACT. It answers a ref/ancestry question — a branch under
2142
2144
  # review reads `wip` — and `merged` there can come from a merge subject with
2143
- # no PR behind it at all. `reached_review` asks the one question that
2145
+ # no PR behind it at all. The PR state below is the one question that
2144
2146
  # outranks the local signals: has this work left the worker's hands?
2145
2147
  local pr_fact=""
2146
- reached_review "$br" && pr_fact="pr"
2148
+ case "$(host_pr_state "$br")" in OPEN|MERGED) pr_fact="pr" ;; esac
2147
2149
  plot_worker_state "$wt" "$pr_fact"
2148
2150
  }
2149
2151
 
@@ -2993,13 +2995,7 @@ add_ref_plan() { # $1=path in ref
2993
2995
  # `$PLAN_DIR` by default, and their symlinks resolve to files already
2994
2996
  # enumerated — counting both would double every plan. `git ls-tree` without
2995
2997
  # `-r` lists one level, and the worktree glob `"$PLAN_DIR"*.md` does not
2996
- # descend either.
2997
- is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2998
- case "$1" in
2999
- ""|NONE) return 1 ;;
3000
- *) return 0 ;;
3001
- esac
3002
- }
2998
+ # descend either. `add_plan_by_phase` below applies the rule.
3003
2999
 
3004
3000
  # ---------------------------------------------------------------------------
3005
3001
  # ONE PARSE FOR THE WHOLE ESTATE
@@ -3229,8 +3225,7 @@ for line in sys.stdin:
3229
3225
  # would have changed was never findable and stays unfindable. The compare is
3230
3226
  # literal, so a path holding `[`, `*` or `?` matches itself and never a glob.
3231
3227
  # THE SEARCH ITSELF, assigning to `plan_meta_index_reply`. Run it in the shell
3232
- # whose cursor should advance; `plan_meta_index_of` below is the stdout wrapper
3233
- # for callers that are already inside a `$(…)`.
3228
+ # whose cursor should advance.
3234
3229
  plan_meta_index_reply=""
3235
3230
  plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
3236
3231
  plan_meta_index_reply=""
@@ -3259,37 +3254,13 @@ plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
3259
3254
  done
3260
3255
  }
3261
3256
 
3262
- # The stdout form, for the two callers that read it inside a command
3263
- # substitution. The cursor it advances belongs to that subshell and dies with
3264
- # it, which costs those callers nothing: both ask once per live plan, and the
3265
- # weight was never there.
3266
- plan_meta_index_of() { # $1=file → index on stdout, or ""
3267
- plan_meta_index_into "$1"
3268
- printf '%s' "$plan_meta_index_reply"
3269
- }
3270
-
3271
- # The phase a file declares, or "" when it is not a plan. Read from the single
3272
- # estate parse above rather than spawned per file.
3273
- plan_phase_of() { # $1=file to parse → normalized phase on stdout
3274
- local i
3275
- i=$(plan_meta_index_of "$1")
3276
- [ -n "$i" ] || { printf ''; return 0; }
3277
- printf '%s' "${plan_meta_phases[$i]}"
3278
- }
3279
-
3280
- # The same answer, ASSIGNED to `plan_phase_reply` instead of printed.
3281
- #
3282
- # THE CURSOR ONLY ADVANCES IN THE PARENT SHELL. `plan_phase_of` is read as
3283
- # `$(plan_phase_of …)` and `plan_meta_index_of` as `$(plan_meta_index_of …)`,
3284
- # and a subshell's write to `plan_meta_cursor` is discarded when it exits — so
3285
- # the resumable search would restart from 0 on every call and the quadratic
3286
- # shape would survive the fix. This form runs in the caller's own shell, which
3287
- # is where `add_plan_by_phase` runs and where the 416 asks come from.
3257
+ # The phase a file declares, or "" when it is not a plan, ASSIGNED to
3258
+ # `plan_phase_reply`. Read from the single estate parse above rather than
3259
+ # spawned per file.
3288
3260
  #
3289
- # A SECOND FUNCTION RATHER THAN A CHANGED CONTRACT. `plan_phase_of` keeps its
3290
- # stdout form for the slug path at `:3308`, which asks once and whose answer is
3291
- # interpolated into an array append. Rewriting that caller to read a global
3292
- # would trade a one-off subshell for a less obvious assignment.
3261
+ # NO STDOUT FORM. A subshell's write to `plan_meta_cursor` is discarded when it
3262
+ # exits, so a `$(…)` reader would restart the search from 0 on every call. Every
3263
+ # caller of this and of `plan_meta_index_into` runs in the scan's own shell.
3293
3264
  plan_phase_reply=""
3294
3265
  plan_phase_into() { # $1=file to parse → sets plan_phase_reply
3295
3266
  plan_phase_reply=""
@@ -3314,6 +3285,80 @@ is_terminal_phase() { # $1=normalized phase → 0 when finished
3314
3285
  esac
3315
3286
  }
3316
3287
 
3288
+ # EVERY PLAN FILE OF THE ESTATE, into `cand_ids` (identity) and `cand_reads`
3289
+ # (the file to parse): the plan directory of `origin/$MAIN`, then each prefixed
3290
+ # branch's plans the default branch does not carry. A named slug reads one plan
3291
+ # and still enumerates the estate, because `SLICE_NAMES` is the estate's set.
3292
+ enumerate_estate() {
3293
+ cand_ids=()
3294
+ cand_reads=()
3295
+ if [ "$PLAN_SOURCE" = "ref" ]; then
3296
+ while IFS= read -r plan_path; do
3297
+ [ -n "$plan_path" ] || continue
3298
+ plan_blob=$(ref_plan_file "$plan_path") || continue
3299
+ [ -n "$plan_blob" ] || continue
3300
+ cand_ids+=("$plan_path")
3301
+ cand_reads+=("$plan_blob")
3302
+ done <<< "$(ref_ls "$PLAN_DIR")"
3303
+
3304
+ # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3305
+ # not carry. Appended to the SAME candidate arrays, before the one
3306
+ # `parse_plan_estate` call below: a second call would build a second
3307
+ # `plan_meta_files` index and the lookup at the row loop keys on the file
3308
+ # path, so the branch plans would parse and then be unfindable.
3309
+ #
3310
+ # From here the existing pipeline carries the plan unchanged — it names its
3311
+ # branch in `## Slices`, the wave walk finds it, and the branch stops
3312
+ # reaching the report through the plan-less loop in `fleet.ts`.
3313
+ #
3314
+ # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3315
+ # every plan path the default branch carries; `seen_branch_plans` is every
3316
+ # path already taken from an earlier branch. Two branches cut from one point
3317
+ # carry the SAME plan file, and without the second test one plan reports as
3318
+ # several — a regression the board measured and fixed, and the reason its
3319
+ # comment exists.
3320
+ #
3321
+ # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3322
+ # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3323
+ # second `for-each-ref`. The narrowing to PR-less branches the slice line
3324
+ # offered was WITHDRAWN by the plan's Design section: it was a fallback
3325
+ # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3326
+ # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3327
+ # a scan whose wall time is 95% waiting.
3328
+ on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3329
+ seen_branch_plans=$'\n'
3330
+ while IFS=$'\t' read -r branch_name _branch_sha; do
3331
+ [ -n "$branch_name" ] || continue
3332
+ [ "$branch_name" = "HEAD" ] && continue
3333
+ [ "$branch_name" = "$MAIN" ] && continue
3334
+ printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3335
+ while IFS= read -r bp; do
3336
+ [ -n "$bp" ] || continue
3337
+ case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3338
+ case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3339
+ plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3340
+ [ -n "$plan_blob" ] || continue
3341
+ # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3342
+ # that could not be read has not been reported, so a later branch
3343
+ # carrying a readable copy of the same path must still get its turn.
3344
+ seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3345
+ # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3346
+ # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3347
+ # the `docs/plans/…md` path is a usable id — and the dedup above is what
3348
+ # guarantees it cannot collide with a default-branch plan's.
3349
+ cand_ids+=("$bp")
3350
+ cand_reads+=("$plan_blob")
3351
+ done <<< "$(branch_plan_paths "$branch_name")"
3352
+ done <<< "$REMOTE_REFS"
3353
+ else
3354
+ for plan_path in "$PLAN_DIR"*.md; do
3355
+ [ -e "$plan_path" ] || continue
3356
+ cand_ids+=("$plan_path")
3357
+ cand_reads+=("$plan_path")
3358
+ done
3359
+ fi
3360
+ }
3361
+
3317
3362
  if [ -n "$slug" ]; then
3318
3363
  # A NAMED SLUG IS NOT A LIST, so it keeps its own resolution: the caller
3319
3364
  # already said which plan it means, and the phase rule would only be able to
@@ -3348,7 +3393,18 @@ if [ -n "$slug" ]; then
3348
3393
  # because that is where every later question reads its answer from.
3349
3394
  if [ ${#plans[@]} -gt 0 ]; then
3350
3395
  parse_plan_estate "${plan_reads[0]}"
3351
- plan_phases+=("$(plan_phase_of "${plan_reads[0]}")")
3396
+ plan_phase_into "${plan_reads[0]}"
3397
+ plan_phases+=("$plan_phase_reply")
3398
+ fi
3399
+ # THE WHOLE ESTATE TOO, when a `waits:` name will be looked up in
3400
+ # `SLICE_NAMES` below: a prerequisite that is a slice of ANOTHER plan must
3401
+ # read the same here as on a full scan (#1305). The plan loop reads only
3402
+ # `plans`, so the extra plans change no row. Asked only when needed, because
3403
+ # it is the cost: measured 2026-10-06 on this estate (ref mode, `--offline`),
3404
+ # a slug scan took 1.6 s alone and 5.2 s with the estate enumerated.
3405
+ if [ "$slice_names" = 1 ] || printf '%s' ${plan_meta_waves[@]+"${plan_meta_waves[@]}"} | awk -F'\t' '$5 != "-" { w = 1 } END { exit !w }'; then
3406
+ enumerate_estate
3407
+ [ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
3352
3408
  fi
3353
3409
  else
3354
3410
  # ---------------------------------------------------------------------------
@@ -3414,7 +3470,8 @@ else
3414
3470
  # lookup cursor's advance, which is the whole saving.
3415
3471
  plan_phase_into "$src"
3416
3472
  ph="$plan_phase_reply"
3417
- is_plan_phase "$ph" || return 0
3473
+ # NOT A PLAN: no phase parsed — see "What makes a file a plan" above.
3474
+ case "$ph" in ""|NONE) return 0 ;; esac
3418
3475
  if is_terminal_phase "$ph"; then
3419
3476
  [ "$next_only" = 1 ] && return 0
3420
3477
  terminal_plans+=("$id")
@@ -3432,73 +3489,7 @@ else
3432
3489
  # ref mode that means every blob is materialized first: the phase decides the
3433
3490
  # group, so the file must exist before it can be asked, and it must be asked
3434
3491
  # together with all the others rather than one at a time.
3435
- cand_ids=()
3436
- cand_reads=()
3437
- if [ "$PLAN_SOURCE" = "ref" ]; then
3438
- while IFS= read -r plan_path; do
3439
- [ -n "$plan_path" ] || continue
3440
- plan_blob=$(ref_plan_file "$plan_path") || continue
3441
- [ -n "$plan_blob" ] || continue
3442
- cand_ids+=("$plan_path")
3443
- cand_reads+=("$plan_blob")
3444
- done <<< "$(ref_ls "$PLAN_DIR")"
3445
-
3446
- # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3447
- # not carry. Appended to the SAME candidate arrays, before the one
3448
- # `parse_plan_estate` call below: a second call would build a second
3449
- # `plan_meta_files` index and the lookup at the row loop keys on the file
3450
- # path, so the branch plans would parse and then be unfindable.
3451
- #
3452
- # From here the existing pipeline carries the plan unchanged — it names its
3453
- # branch in `## Slices`, the wave walk finds it, and the branch stops
3454
- # reaching the report through the plan-less loop in `fleet.ts`.
3455
- #
3456
- # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3457
- # every plan path the default branch carries; `seen_branch_plans` is every
3458
- # path already taken from an earlier branch. Two branches cut from one point
3459
- # carry the SAME plan file, and without the second test one plan reports as
3460
- # several — a regression the board measured and fixed, and the reason its
3461
- # comment exists.
3462
- #
3463
- # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3464
- # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3465
- # second `for-each-ref`. The narrowing to PR-less branches the slice line
3466
- # offered was WITHDRAWN by the plan's Design section: it was a fallback
3467
- # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3468
- # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3469
- # a scan whose wall time is 95% waiting.
3470
- on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3471
- seen_branch_plans=$'\n'
3472
- while IFS=$'\t' read -r branch_name _branch_sha; do
3473
- [ -n "$branch_name" ] || continue
3474
- [ "$branch_name" = "HEAD" ] && continue
3475
- [ "$branch_name" = "$MAIN" ] && continue
3476
- printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3477
- while IFS= read -r bp; do
3478
- [ -n "$bp" ] || continue
3479
- case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3480
- case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3481
- plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3482
- [ -n "$plan_blob" ] || continue
3483
- # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3484
- # that could not be read has not been reported, so a later branch
3485
- # carrying a readable copy of the same path must still get its turn.
3486
- seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3487
- # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3488
- # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3489
- # the `docs/plans/…md` path is a usable id — and the dedup above is what
3490
- # guarantees it cannot collide with a default-branch plan's.
3491
- cand_ids+=("$bp")
3492
- cand_reads+=("$plan_blob")
3493
- done <<< "$(branch_plan_paths "$branch_name")"
3494
- done <<< "$REMOTE_REFS"
3495
- else
3496
- for plan_path in "$PLAN_DIR"*.md; do
3497
- [ -e "$plan_path" ] || continue
3498
- cand_ids+=("$plan_path")
3499
- cand_reads+=("$plan_path")
3500
- done
3501
- fi
3492
+ enumerate_estate
3502
3493
 
3503
3494
  # ONE INVOCATION FOR THE WHOLE ESTATE. Everything below reads its result.
3504
3495
  [ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
@@ -3514,6 +3505,19 @@ else
3514
3505
  done
3515
3506
  fi
3516
3507
 
3508
+ # EVERY SLICE SOMEBODY MAY STILL START, newline-framed for a `case` lookup: the
3509
+ # non-deferred slices (wave field 3) of every parsed plan whose phase is not
3510
+ # terminal. Every mode parses the whole estate, so a slug run, `--next` and the
3511
+ # full scan hold one set, and `plot-dispatch.sh` asks `--slice-names` for it
3512
+ # rather than reading its own plan. A `waits:` name in the set with no pull
3513
+ # request reads `waiting`; any other name with none reads `blocked` (#1305).
3514
+ # `namedSlices` in `rules/branch-state.ts` states the same rule, and
3515
+ # `branch-state.corpus.test.ts` holds the two to one answer.
3516
+ live_waves=()
3517
+ for _si in "${!plan_meta_files[@]}"; do is_terminal_phase "${plan_meta_phases[$_si]}" || live_waves+=("${plan_meta_waves[$_si]}"); done
3518
+ SLICE_NAMES=$'\n'"$(printf '%s' ${live_waves[@]+"${live_waves[@]}"} | awk -F'\t' '$3 != "true" && !seen[$2]++ { print $2 }')"$'\n'
3519
+ [ "$slice_names" = 1 ] && { printf '%s' "$SLICE_NAMES" | sed '/^$/d'; exit 0; }
3520
+
3517
3521
  if [ ${#plans[@]} -eq 0 ]; then
3518
3522
  # --next/--list-eligible must stay silent and exit 1: "nothing to start" is
3519
3523
  # the same answer whether the plans are all claimed or there are no plans at
@@ -3687,7 +3691,8 @@ if [ ${#plans[@]} -gt 0 ] && [ -n "$MERGE_SUBJECTS" ]; then
3687
3691
  # — `$plan_reads[i]`, the same key the row loop uses at pass 1a. In ref mode
3688
3692
  # that is a materialized blob under a temp path and not `$PLAN_DIR` at all,
3689
3693
  # so a reconstructed path finds nothing.
3690
- _sp_meta_i=$(plan_meta_index_of "${plan_reads[$_sp_i]}" 2>/dev/null || echo "")
3694
+ plan_meta_index_into "${plan_reads[$_sp_i]}"
3695
+ _sp_meta_i=$plan_meta_index_reply
3691
3696
  [ -n "$_sp_meta_i" ] || continue
3692
3697
  _sp_branches=""
3693
3698
  while IFS=$'\t' read -r _sp_idx _sp_br _sp_rest; do
@@ -4109,7 +4114,7 @@ branch_readings() { # $1=branch $2=deferred $3=plan-base → readings
4109
4114
  # There is no shell fallback: a second implementation kept "just in case" is
4110
4115
  # the duplication this adoption removes, and it would be the copy nobody tests.
4111
4116
  # `plot-deliver.sh` fails the same way for the same reason.
4112
- ask_branch_states() { # stdin=readings → one `state<TAB>needs` line per branch
4117
+ ask_branch_states() { # stdin=readings → one `state<TAB>needs<TAB>own` line per branch
4113
4118
  node "$script_dir/board/plot-branch-state.mjs" 2>/dev/null \
4114
4119
  || { echo "error: cannot read branch states — run 'pnpm build:board'." >&2; exit 2; }
4115
4120
  }
@@ -4227,7 +4232,8 @@ for plan in "${plans[@]}"; do
4227
4232
  # The estate was parsed ONCE, before this loop. A plan absent from that
4228
4233
  # result could not be read at all, which is the same answer the per-plan
4229
4234
  # parse gave by failing — so it is skipped here exactly as it was then.
4230
- meta_i=$(plan_meta_index_of "$plan_read")
4235
+ plan_meta_index_into "$plan_read"
4236
+ meta_i=$plan_meta_index_reply
4231
4237
  [ -n "$meta_i" ] || continue
4232
4238
 
4233
4239
  # The plan's own phase, carried onto the pulse so a consumer can derive a row
@@ -4339,7 +4345,7 @@ for plan in "${plans[@]}"; do
4339
4345
  # not evidence in either direction. The two were one marker until CI ran the
4340
4346
  # corpus with no token, where every prerequisite answers `-` and every waiting
4341
4347
  # branch read `open`. Reading it
4342
- # costs a host round trip (`waits_pr_state` passes `--ask`, because a delivered
4348
+ # costs a host round trip (`host_pr_state … --ask`, because a delivered
4343
4349
  # prerequisite's ref is gone and only its PR outlives it), and the scan spends
4344
4350
  # that only where the answer could change the branch's state. Which states
4345
4351
  # those are IS the precedence, so the rule reports it rather than this loop
@@ -4363,7 +4369,7 @@ for plan in "${plans[@]}"; do
4363
4369
  # "-" is the absent marker the shim writes, for the tab-collapse reason
4364
4370
  # above. Normalized here so everything downstream tests emptiness.
4365
4371
  [ "$waits" = "-" ] && waits=""
4366
- readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete"$'\n'
4372
+ readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete ?"$'\n'
4367
4373
  # Read from the readings line just built rather than from the variable: the
4368
4374
  # subshell `branch_readings` runs in cannot export it back.
4369
4375
  # FIELD 6 is the host's own word, in both arms of `branch_readings`:
@@ -4417,7 +4423,7 @@ for plan in "${plans[@]}"; do
4417
4423
  while IFS= read -r rd_line; do
4418
4424
  [ -n "$rd_line" ] || continue
4419
4425
  answer_i=$((answer_i + 1))
4420
- IFS=$'\t' read -r _st needs \
4426
+ IFS=$'\t' read -r _st needs _ \
4421
4427
  <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
4422
4428
  waits_br=$(printf '%s' "$rd_line" | cut -f9)
4423
4429
  if [ "$needs" = "1" ] && [ "$waits_br" != "-" ]; then
@@ -4428,15 +4434,21 @@ for plan in "${plans[@]}"; do
4428
4434
  # run, never one per pass.
4429
4435
  #
4430
4436
  # FIELD 9 IS A LIST, comma-joined, so field 10 answers each prerequisite
4431
- # IN THE SAME ORDER — one `waits_pr_state` call per name, joined the same
4437
+ # IN THE SAME ORDER — one `host_pr_state … --ask` call per name, joined the same
4432
4438
  # way. `entry/branch-state.ts` reads the two columns as parallel lists of
4433
4439
  # equal length and throws otherwise, so a single answer for several names
4434
- # would desync them. Field 11 is carried, unreplaced.
4435
- waits_state=""
4440
+ # would desync them. Field 11 is carried, unreplaced. Field 12 says, in
4441
+ # the same order, whether `SLICE_NAMES` holds each name.
4442
+ #
4443
+ # A NAMED SLICE WITH NO REF IS STILL ASKED. Its ref may be gone because it
4444
+ # merged and `plot-release-refs.sh` reaped it, and only the host's MERGED
4445
+ # clears the wait; skipping the call would hold the branch forever.
4446
+ waits_state="" waits_named=""
4436
4447
  for _wn in ${waits_br//,/ }; do
4437
- waits_state+="${waits_state:+,}$(waits_pr_state "$_wn")"
4448
+ waits_state+="${waits_state:+,}$(host_pr_state "$_wn" --ask)"
4449
+ case "$SLICE_NAMES" in *$'\n'"$_wn"$'\n'*) waits_named+="${waits_named:+,}true" ;; *) waits_named+="${waits_named:+,}false" ;; esac
4438
4450
  done
4439
- refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11)"$'\n'
4451
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11) $waits_named"$'\n'
4440
4452
  else
4441
4453
  refill+="$rd_line"$'\n'
4442
4454
  fi
@@ -4454,13 +4466,14 @@ for plan in "${plans[@]}"; do
4454
4466
  # record is re-read by two more `read` loops below, and an EMPTY middle
4455
4467
  # column collapses its tab into its neighbour's and shifts every later
4456
4468
  # field left. `$claim` is the only field allowed to be last and optional.
4469
+ # `$own` is the branch's state before its prerequisite, the third answer column.
4457
4470
  states=""
4458
4471
  answer_i=0
4459
4472
  while IFS=$'\t' read -r idx br deferred why waits wname claim; do
4460
4473
  [ -n "$br" ] || continue
4461
4474
  answer_i=$((answer_i + 1))
4462
- st=$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p" | cut -f1)
4463
- states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
4475
+ IFS=$'\t' read -r st _ own <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
4476
+ states+="$idx $br $st $deferred $why $waits $wname ${own:-$st} $claim"$'\n'
4464
4477
  done <<< "$order"
4465
4478
 
4466
4479
  # PASS 1e: A DELIVERY CANDIDATE ASKS THE HOST about its subject-proven branches.
@@ -4519,7 +4532,7 @@ for plan in "${plans[@]}"; do
4519
4532
  outstanding=0
4520
4533
  _loose_degraded_branches=""
4521
4534
  wave_states=""
4522
- while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
4535
+ while IFS=$'\t' read -r idx br st deferred why waits nm _ claim; do
4523
4536
  [ "$idx" = "$wid" ] || continue
4524
4537
  # EVERY branch, including the deferred ones, and in the order the render
4525
4538
  # loop below will walk them — the claimable flags come back positionally,
@@ -4618,7 +4631,7 @@ for plan in "${plans[@]}"; do
4618
4631
  # `deferred` to tell a branch that will never move from one that has not
4619
4632
  # moved yet.
4620
4633
  outlook_branches=""
4621
- while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
4634
+ while IFS=$'\t' read -r idx br st deferred why waits nm own claim; do
4622
4635
  [ "$idx" = "$wid" ] || continue
4623
4636
  outlook_branches+="${outlook_branches:+|}$br:$st"
4624
4637
  [ "$claim" = "-" ] && claim=""
@@ -4637,9 +4650,9 @@ for plan in "${plans[@]}"; do
4637
4650
  # whole of what this state adds over `open`.
4638
4651
  waiting) n_waiting=$((n_waiting + 1))
4639
4652
  note="waiting on $waits_prose" ;;
4640
- # A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
4641
- # and answered, because that is what separates this from `waiting`: a
4642
- # host that could not be asked holds the branch at `waiting` instead.
4653
+ # A PREREQUISITE NOBODY DECLARED: no plan names it as a slice, and the
4654
+ # host answered that no PR exists for it. A host that could not be
4655
+ # asked, or a named slice nobody has started, reads `waiting` instead.
4643
4656
  blocked) n_prereq_missing=$((n_prereq_missing + 1))
4644
4657
  note="blocked — no PR found for $waits_prose" ;;
4645
4658
  # The REASON, where the plan recorded one. A bare `deferred` beside a
@@ -4683,7 +4696,7 @@ for plan in "${plans[@]}"; do
4683
4696
  # The INTERNAL state ($st), never the prose label ($note): the board
4684
4697
  # must not parse a string that exists for humans to read.
4685
4698
  json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
4686
- json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
4699
+ json_branches+=",\"state\":\"$st\",\"own_state\":\"$own\",\"deferred\":$deferred"
4687
4700
  # WHAT PROVED THE LANDING, where the proof is weaker than the host's.
4688
4701
  #
4689
4702
  # EMITTED ONLY WHERE THE BRANCH READS `merged` AND THE HOST HAS NOT