#!/bin/sh
# beadprompt.sh — emit the swarm prompt for a bead and copy it to the clipboard.
#
#   ./beadprompt.sh wwwdb-i7e     one bead
#   ./beadprompt.sh               next HANDABLE bead in BATCH, and show the queue
#   ./beadprompt.sh --all         print every BATCH prompt, copy nothing
#
# Edit BATCH to change the queue. Requires bd on PATH; pbcopy is optional.
#
# NOTE: this file is in .gitignore — git will not bring it back for you. It has
# been lost once already. If you are about to delete it, copy it somewhere first.
#
# The scheduling lists below came from beadprompt-next.sh, which was a
# throwaway for one batch and is now folded back in — a batch almost always has
# at least one bead that cannot run beside its neighbour, and hand-remembering
# which is how two agents end up in one file.
#
#   SOLO  one bead per line. Never handed out while anything else in BATCH is
#         claimed, and claiming it makes everything else unhandable.
#   SEQ   "child:parent". The child is not handed out until the parent CLOSES.
#   EXCL  "A:B". Symmetric, no ordering implied — shared files. A is held while
#         B is claimed and B while A is.
#   LAST  held until every other bead in BATCH is CLOSED. For a bead that
#         changes a GATE everyone else has to pass.


BATCH="wwwdb-epj.9 wwwdb-epj.7 wwwdb-3jq.1 wwwdb-yoj wwwdb-epj.10 wwwdb-epj.8 wwwdb-8cx.5 wwwdb-epj.1 wwwdb-e5tk"

SOLO=""
SEQ="wwwdb-epj.1:wwwdb-8cx.5"
EXCL="wwwdb-epj.9:wwwdb-epj.7 wwwdb-3jq.1:wwwdb-yoj wwwdb-epj.10:wwwdb-epj.8"
LAST="wwwdb-e5tk"
#
# THE REACHABILITY ROUND.
#
# The previous batch closed nine beads and did NOT close wwwdb-epj, which it
# was framed as closing: it filed four new children on the way through and the
# epic stands at 14/20. Read why before picking anything up, because it is the
# shape of this batch. Three separate agents ported code that is CORRECT and
# that nothing can reach:
#
#   * wwwdb-qhn built sortVisualSides but not FitTexturesForm, so Ctrl+F fires
#     once at fixed defaults where UDB always shows a live dialog  -> epj.7.
#   * wwwdb-960 built the rotation and scale actions with no key binding at
#     all                                                          -> epj.9.
#   * wwwdb-epj.2 built brightenExtraCeiling, which nothing can aim at because
#     pick.js cannot pick a 3D floor                               -> 3jq.1.
#
# That is not three coincidences, it is a standing hazard of a port organised
# by UDB class: the class gets ported, the thing that CALLS it in UDB is a
# different class in a different file, and it does not. If you finish your
# bead and your code has no caller, you have not finished -- file the wiring
# and say so in the close reason, the way 960 did.
#
#   epj.9   P2, epic epj. Wire rotation/scale into visualpane.js and view.html.
#           Files: visualpane.js, view.html.
#   epj.7   P2, epic epj. FitTexturesForm itself, live-updating over qhn's
#           sortVisualSides. Files: dialogs.js, visualpane.js, and a DERIVED
#           table (DIALOG_LAYOUT) -- re-dump it, never hand-merge it.
#   3jq.1   P2, epic 3jq. Filed 2026-09-14 off epj.2's close. pick.js emits two
#           flat candidates per sector where UDB enumerates FixedGeometry.
#           Files: pick.js, visualpane.js (the pickObject call at :1523).
#   yoj     P3, epic epj. The VERTEX and SLOPE-HANDLE selection categories.
#           Geometry FIRST, selection second -- nothing builds a
#           VisualVertexHandle or VisualSlopeHandle at all. Files:
#           visualgeometry.js, visualselect.js, pick.js.
#   epj.10  P2, epic epj. Thing insert and delete. The one REMOVAL bead this
#           round. Files: visualthing.js, topology.js.
#   epj.8   P2, epic epj. BaseVisualThing.OnChangeScale. Files: visualthing.js.
#   8cx.5   P2, epic 8cx. Filed 2026-09-14. Thread a parsed MapInfo into the
#           data layer -- the WIRING, not the parser and not the rest of
#           DataManager. Files: resources.js, mapinfo.js, visualpane.js.
#   epj.1   P2, epic epj. SEQ behind 8cx.5. UpdateLightFogFlag. Held for two
#           batches because it was pinned behind wwwdb-9w3 (P4); re-pointed
#           2026-09-14 at 8cx.5, which is the only thing it actually needed.
#           Files: visualedit.js.
#   e5tk    P2. LAST. Rolls citecheck's PORT_CLAIMS across the rest of the
#           tree. Files: citecheck.js.
#
# The conflict map:
#
#   epj.9 and epj.7 are an EXCL pair. Both rework the SAME region of
#   visualpane.js -- epj.9 adds key bindings beside the Ctrl+F handler, epj.7
#   replaces what that handler does. visualpane.js is a declared shared APPEND
#   point and that exemption does NOT cover two agents editing one region.
#
#   3jq.1 and yoj are an EXCL pair, and the reason is precise: the slope-handle
#   pick branches yoj needs (VisualMode.cs:919-926) are inside the SAME
#   candidate loop 3jq.1 is rewriting (:913-918). Same function, not just the
#   same file.
#
#   epj.10 and epj.8 are an EXCL pair -- both are BaseVisualThing, both land in
#   visualthing.js.
#
#   Everything else runs concurrently. 3jq.1 and yoj both touch pick.js while
#   epj.9/epj.7 touch visualpane.js in a different region; that is deliberate,
#   not an oversight. Four agents at once is the intent.
#
#   e5tk is LAST because it changes a GATE every other bead in this batch has
#   to pass, in a file every one of them appends to. The slot has been empty
#   for two batches and this is what it was built for.
#
# Standing hazard carried forward from the 2026-09-14 review:
#
#   The previous round's commits were made in one pass at the end and TWO went
#   wrong. wwwdb-epj.6's tests were committed while its implementation was not
#   (HEAD ran 1182/1187 red until it was fixed up), and wwwdb-1sh got no commit
#   of its own at all -- its whole port rode in under epj.2's message. Neither
#   is a porting defect and both were invisible to a green working tree,
#   because the working tree HAD everything. If you are the one committing this
#   round, commit per bead and run the gates from a CLEAN CHECKOUT of what you
#   committed, not from the tree you were working in.

DIR="$HOME/Desktop/Work/WWWDB/udmf-editor-foundation"

extra() {
  case "$1" in
  wwwdb-epj.9) cat <<'EOF'

SPECIFIC TO THIS BEAD

wwwdb-960 ported the actions and could not reach them. You are the caller, and
the bindings are DATA, not a design problem — Actions.cfg has them and this
project already derives that file:

  scaleup      105  (Num 9)      scaledown    103  (Num 7)
  scaleupx     102               scaledownx   100
  scaleupy     104               scaledowny   101
  rotateclockwise         262138  (Ctrl + Shift + ScrollUp)
  rotatecounterclockwise  262139  (Ctrl + Shift + ScrollDown)

Read those out of Actions.cfg yourself rather than trusting this list, and if
they belong in a derived table, DUMP them — do not hand-type eight rows.

960's close reason worried that "this pane's wheel is already claimed by FOV
zoom". Check that before you design around it: rotate is Ctrl+Shift+Scroll, a
MODIFIED wheel, and visualpane.js:274's FOV handler takes the PLAIN wheel. If
that is right, there is no conflict to resolve and nothing to invent. If the
handler swallows modified wheel events too, that is the bug to fix — not a
reason to pick a different key than UDB's.

The six scale actions all dispatch through ONE private ScaleTexture(incrementx,
incrementy) (BaseVisualMode.cs:3486-3498), which opens
PreAction(UndoGroup.TextureScaleChange) and then loops OnChangeScale over the
selected objects. Port that dispatcher shape; six independent handlers that
each open their own undo is drift, and the undo GROUP is the part that shows.

The fixture trap: a binding test that asserts "the handler was called" passes
whether or not the right increment reached it. Assert on the (incrementX,
incrementY) pair each action produces — six actions, six distinct pairs, and
the x/y-only ones are where a transposition hides.

You share visualpane.js with wwwdb-epj.7, which is held while you are claimed.
EOF
;;
  wwwdb-epj.7) cat <<'EOF'

SPECIFIC TO THIS BEAD

The engine is done and you must not rebuild it. wwwdb-qhn landed
sortVisualSides/sidedefVertexBounds/fitSortedSide in visualedit.js and wired
Ctrl+F to run them at DEFAULT_FIT_OPTIONS' fixed values. You are adding the
DIALOG that UDB always shows for visualfittextures (BaseVisualMode.cs
:3659-3687) and that live-updates on every control change (UpdateChanges,
FitTexturesForm.cs:120-148). If you find yourself computing a strip or a
bound, stop — that is qhn's code and you should be calling it.

Do not invent a layout. This is a two-column dialog and the project has a
derived DIALOG_LAYOUT with a checker: re-dump it from the designer geometry,
never hand-merge it, and run dialogparity.js --layout and --dialog. The
ui-conventions memory and the dialogs already ported are the pattern.

Two behaviours that are easy to miss and are the whole point of the dialog:
"Fit across connected surfaces" is ENABLED only when strips.Count > 1
(FitTexturesForm.cs:107) and ships CHECKED (:50-54) — qhn already flipped
DEFAULT_FIT_OPTIONS.fitAcrossSurfaces to true to match, so do not flip it
back. And UpdateChanges re-runs the fit on EVERY control change against the
ORIGINAL offsets, not cumulatively against the last preview. A dialog that
applies its delta on top of its own previous preview drifts every keystroke,
and a fixture that changes one control once cannot tell the two apart — change
a control THREE times and assert the result equals the one-shot answer.

Modals must not leak events to the map: modalOpen() and typingInField() gate
every mouse and key handler. Any new global handler needs both.

You share visualpane.js with wwwdb-epj.9, which is held while you are claimed.
EOF
;;
  wwwdb-3jq.1) cat <<'EOF'

SPECIFIC TO THIS BEAD

Filed 2026-09-14 out of wwwdb-epj.2's close, and it is the reason three rounds
of 3D-floor work are still unreachable: pick.js pushes exactly TWO flat
candidates per sector (:346-347 for the camera's own sector, :362-363 for every
sector the ray touches) where UDB enumerates vs.FixedGeometry — every entry
with Triangles > 0 (VisualMode.cs:913-918).

BaseVisualSector.Rebuild says exactly what is in that list, and it is more than
two: the plain floor (:217), the plain ceiling (:221), then PER EXTRA FLOOR a
VisualFloor (:256) and, when RenderInside, its backside (:262); per extra
ceiling a VisualCeiling (:294) and its backside (:300). All through the same
base.AddGeometry. wwwdb-phs built the stack and wwwdb-4p1x built the backside
pass, so the geometry ALREADY EXISTS in visualgeometry.js — you are not
building surfaces, you are enumerating the ones that are there.

Port the ENUMERATION, not "two plus extra floors". If your candidate list reads
as the old pair with a loop bolted beside it, you have written the special case
again — the plain floor and ceiling are the degenerate SectorLevel stack, which
is exactly the framing wwwdb-phs was given and met.

Not yours, and in the same loop: the pickingmode slope-handle branches
(VisualMode.cs:919-926) belong to wwwdb-yoj, which is held while you are
claimed for precisely that reason. Leave them alone; do not "while I am here".

The fixture trap: a sector with no 3D floor produces the same two candidates
either way and cannot tell a correct enumeration from today's hardcode. Build a
sector with at least one extra floor AND one extra ceiling, aim a ray that
crosses the extra floor's plane BEFORE the real ceiling, and assert on WHICH
surface comes back — never on the candidate count.

Prove the payoff: wwwdb-epj.2's brightenExtraCeiling is currently exercised only
by direct call because nothing can pick an extra ceiling. If it is reachable
when you are done, say so in the close reason.
EOF
;;
  wwwdb-yoj) cat <<'EOF'

SPECIFIC TO THIS BEAD

Read the bead body before you plan: "both categories have no visual geometry
here at all — nothing builds a VisualVertexHandle or a VisualSlopeHandle". This
is GEOMETRY first and selection second, and the selection half really is small
because visualselect.js already carries both through its filter and clear
arguments. If you scope this as "adding a case", you will have written the
selection half of a feature with nothing to select.

The pick side is VisualMode.cs:919-926 — the pickingmode branches
(sidedefslopehandles / vertexslopehandles) inside the candidate loop. That loop
is being rewritten by wwwdb-3jq.1, which is held while you are claimed and
which you are held behind: SAME function, not just the same file. Whichever of
you goes second re-reads the loop on disk before patching it.

Do not extend this into slope EDITING. The handles' selection and geometry are
the bead; dragging one to change a plane is not, and if you find yourself in
planes.js, file it.
EOF
;;
  wwwdb-epj.10) cat <<'EOF'

SPECIFIC TO THIS BEAD

This is the REMOVAL bead this round, and removal in this document model
RENUMBERS — UDMF references are positional. Everything that deletes goes
through topology.js's disposal cascade and syncIndices, never a raw block
splice. HANDOFF's "Two things to know before touching removal" is required
reading before your first edit. Nothing else in this batch is near topology.js
and that is deliberate; keep it that way.

Note carefully that this is NOT shaped like wwwdb-1sh, which found OnInsert
dead and OnDelete to be texture bookkeeping. On BaseVisualThing both halves are
real, and they live in different places:

  * INSERT is not on the class at all — BaseVisualThing.OnInsert() is an empty
    body (BaseVisualThing.cs:672). The real action is BaseVisualMode.
    InsertThing (:3885-3916), bound to "insertitem": GetHitPosition(), a
    finite-check that DISPLAYS A WARNING and returns, ClearSelection,
    PreActionNoChange, ClearAllRedos, CreateUndo("Insert thing"), CreateThing,
    and a WithdrawUndo when CreateThing answers null. Port the withdraw — an
    undo level left open on a failed insert is exactly the kind of "cleaner"
    omission section 0 is about.
  * DELETE is BaseVisualThing.OnDelete (:699-709) and it is genuine disposal:
    Thing.Fields.BeforeFieldsChange(), Thing.Dispose(), this.Dispose(), then
    ThingsFilter.Update(). wwwdb-ahg.1 already made the filter a live consumer,
    so that last line has a real caller here.

The fixture trap follows from the renumbering: a map where the deleted thing is
the LAST one cannot tell a correct renumber from no renumber at all, because
nothing shifts. Delete from the MIDDLE and assert on the references that had to
move, not on the count.

You share visualthing.js with wwwdb-epj.8, which is held while you are claimed.
EOF
;;
  wwwdb-epj.8) cat <<'EOF'

SPECIFIC TO THIS BEAD

You are porting the SIBLING of something that landed last round, so read it
first: wwwdb-960's changeWallTextureScale in visualedit.js is the same
pixel-preserving formula BaseVisualThing.OnChangeScale (:819-850) uses —

    pix = round(dimension * scale) + increment
    newscale = round(pix / dimension, 3)
    newscale == 0 ? scale * -1 : newscale

including the zero-to-sign-flip. Match that shape. A second convention for the
same arithmetic in a second file is drift even when both are correct.

Three things that are NOT in the wall version and are yours:

  * The gate is `!General.Map.UDMF || sprites == null ||
    !sprites[0].IsImageLoaded` (:821). It needs a LOADED SPRITE, because the
    formula divides by the sprite's pixel dimensions. If sprite loading is not
    there yet, that is a dependency to FILE, not to guess a dimension around —
    a fallback of 1 would silently produce a wrong scale rather than no scale.
  * Undo COALESCING: `NextUndo == null || NextUndo.TicketID != undoticket`
    (:823-824) opens one undo level across a held repeat instead of one per
    increment. A fixture that calls it once cannot see this. Call it three
    times and assert ONE undo level.
  * It writes through Thing.SetScale and sets this.Changed, where the wall
    version writes UDMF fields directly.

You share visualthing.js with wwwdb-epj.10, which is held while you are claimed.
EOF
;;
  wwwdb-8cx.5) cat <<'EOF'

SPECIFIC TO THIS BEAD

Filed 2026-09-14. You are unblocking wwwdb-epj.1, which has been parked for two
batches behind wwwdb-9w3 (P4, two thirds of DataManager's 3,882 lines) when all
it ever needed was this. epj.1 is SEQ'd behind you in this batch, so a clean
close here hands a second bead to the next agent.

The scope is the WIRING and nothing else. The parser exists — wwwdb-aiu ported
MapinfoParser + MapInfo into mapinfo.js from MapinfoParser.cs / MapInfo.cs, and
wwwdb-kdv.7 already consumes its DoomEdNum/SpawnID tables. What does not exist
is any path by which the editor HOLDS a parsed MapInfo for the open map. UDB's
is General.Map.Data.MapInfo, set when the map loads. Find the MAPINFO/ZMAPINFO
lump through the resource layer, parse it with the parser that is already
there, hang the result where visual mode can read it, and answer with MapInfo's
OWN documented defaults when the map has no such lump.

Do not start consuming what you thread. The sky/fog/lightmode fields and the
fade/outsidefog colours (mapinfo.js:95-97, :233-238) are parsed but unconsumed
on purpose; consuming them is wwwdb-1n5's rendering work. Thread the OBJECT.

The defaults are the whole risk, and the safety argument recorded on epj.1 on
2026-09-10 is why: a map with no MAPINFO must come back with MapInfo's real
defaults, not with every term false. A downstream flag decision that reads a
false it should not have would CLEAR a correct flag on a map authored in real
UDB — worse than not touching it. So the discriminating fixture is a map with
NO MAPINFO lump: assert the defaults, not merely that nothing threw.
EOF
;;
  wwwdb-epj.1) cat <<'EOF'

SPECIFIC TO THIS BEAD

You are SEQ'd behind wwwdb-8cx.5 and must not start until it CLOSES. Read what
it landed before you read your own bead body.

This bead's description carried a factual error until 2026-09-13 and the
correction is the reason it is P2 and not a footnote. It claimed the
UpdateLightFogFlag call only fires for a plain `light` key, exempting the
distinct-PART case. It does not: Tools.cs:2437's early-out fires only when NONE
of light / light_top / light_mid / light_bottom is present, so the call fires
for all four, and BaseVisualGeometrySidedef.cs:1595-1623 is one branch in which
fieldname becomes light_<partname>. The DISTINCT-PART case is the most affected
one. Build the fixture on a distinct part, not on a plain `light`.

Of the three blockers once recorded, two are already gone: hasSkyCeiling exists
(visualpane.js:93 = Sector.cs:109) and `lightfog` is in the field model
(defaults.js:110). What is left is a sidedef SetFlag equivalent — and note
wwwdb-vse added exactly such a helper for the unpegged toggles last round
(setLineFlag, which omits a false rather than storing it, matching
UniversalStreamWriter.cs:235). Read it before you write a second one.

The safety rule still governs: do NOT run this against MapInfo terms you had to
assume. If 8cx.5's defaults are not actually reaching you, stop and say so —
clearing a correct lightfog flag on a map authored in real UDB is worse than
leaving the flag alone.
EOF
;;
  wwwdb-e5tk) cat <<'EOF'

SPECIFIC TO THIS BEAD

You are LAST: every other bead in this batch is CLOSED before you are handed
out. That is not politeness, it is the point — you are changing a GATE all of
them had to pass, in a file every one of them appended a claim to. Run
citecheck BEFORE your first edit and write the number down, so a count that
moves later is attributable.

wwwdb-o0c landed Check 3 as a PILOT, deliberately scoped to three known
instances. This is the ROLLOUT, and there are two classes:

  Class A — PORT drift. PORT_CLAIMS has exactly ONE entry (stitch.js's second
  SplitLinesByVertices call, MapSet.cs:2223). Every ported call site that
  passes an argument, flag or condition that CLAIMS already pins on the
  REFERENCE side is a candidate that has never been checked against the port.
  Class B — unregistered inline claim: prose that quotes what a cited line says
  but was never promoted into CLAIMS, so only the cheap in-range check ever ran.

Audit every MODULE in citecheck.js's MODULES list, not just selftest.js. And
audit the MODULES list itself first: a module that is not in it is not partly
checked, it is UNCHECKED, and the suite stays green either way. That has hidden
a bad citation three times in this project, and wwwdb-1ze was filed because the
list was missing selftest.js and citecheck.js themselves.

The last round is fresh material and the most likely place to find Class A:
nine beads landed ports across visualedit.js, visualgeometry.js,
sectorlevels.js, visualautoalign.js, visualselect.js and visualpane.js, several
of them widening code an EARLIER bead wrote against a collapsed assumption
(epj.3's controlSide === sidedef is the worked example). Start there.

A found instance is a RESULT, not a failure — report the count either way. If
the sweep finds nothing in a module, say which modules you cleared, so the next
person does not redo it.
EOF
;;
  esac
}
emit() {
  case " $SOLO " in
    *" $1 "*) _co="You are running SOLO: no other agent should be working this tree while
you are. The shared-file cautions below still apply to what you LEAVE behind." ;;
    *)        _co="Other agents are working other beads in this same tree at the same time." ;;
  esac
  cat <<EOF
Work bead $1 in $DIR.

$_co

- Run \`bd update $1 --claim\` BEFORE you start. That removes it from
  \`bd ready\` so nobody else takes it.
- Re-read a file immediately before you patch it. Keep edits surgical. Never
  restore a file wholesale from a backup without diffing it against disk first.
- Do NOT read a changed self-test COUNT as evidence about your own change —
  it moves for reasons that have nothing to do with you. Diff the test NAMES.
- \`selftest.js\`, \`view.html\` and \`visualpane.js\` are shared append points.
  Expect your edit to land beside someone else's, and do not reformat around
  theirs.
- If you hit a conflict in a DERIVED table (DIALOG_LAYOUT, UDB_CONTROL,
  INFO_PANELS, SHAPE_OPTIONS, CONFIG_FORM, MODE_*, TOOLBAR*), do NOT hand-merge
  it. Re-run the dumper and let its checker prove the result.
- If you add a MODULE, add it to citecheck.js's MODULES list in the same
  change. A module that is not in MODULES is not partially checked, it is
  unchecked, and the suite stays green either way. This has now hidden a bad
  citation three times in this project.
- A citation that RESOLVES is not a citation that is OBEYED. citecheck pins the
  REFERENCE line; it cannot see that the code beside it does something else.
  Read the WHOLE referenced function and check the port against UDB's source —
  never against the comment above it. Three instances of this are on record,
  one of them a real drift found only by a human reading the diff.
- An empty grep over \`refs/\` is not evidence the reference is absent — the
  shell's grep here is ugrep with -I and prints NOTHING for a file carrying one
  byte of invalid UTF-8. Search with /usr/bin/grep, or run ./fetch-references.sh
  first. If the file is genuinely not in refs/, it may simply never have been in
  fetch-references.sh's manifest (this has happened twice); check that before
  concluding UDB does not have the feature.
- Do NOT commit and do NOT push. Leave the tree dirty.

Before you close the bead:
- every gate green (the list is in CLAUDE.md), and
- mutation-test your own change: revert each behaviour you added and confirm
  something goes red. A fixture that answers the same either way proves
  nothing — fix the fixture, not the assertion.

If you stop at a dependency, file it as a bead rather than leaving it in a
code comment.

Report back: files changed, gate results, what you mutation-tested and whether
each was caught, and anything you deferred.
EOF
  extra "$1"
}

status_of() { bd show "$1" 2>/dev/null | head -1 | grep -oE 'OPEN|IN_PROGRESS|CLOSED'; }

if [ "$1" = "--all" ]; then
  for b in $BATCH; do echo "########## $b"; emit "$b"; echo; done; exit 0
fi

if [ -n "$1" ]; then TARGET="$1"; else
  TARGET=""; done_n=0; busy=""; todo=""; held=""
  anybusy=""; anyopen=""
  for b in $BATCH; do
    st=$(status_of "$b")
    [ "$st" = "IN_PROGRESS" ] && anybusy="$anybusy $b"
    case " $LAST " in *" $b "*) ;; *) [ "$st" != "CLOSED" ] && anyopen="$anyopen $b" ;; esac
  done
  for b in $BATCH; do
    line=$(bd show "$b" 2>/dev/null | head -1)
    st=$(echo "$line" | grep -oE 'OPEN|IN_PROGRESS|CLOSED'); [ -z "$st" ] && st="?"
    case "$line" in *"(EPIC)"*|*"[EPIC]"*) echo "  skipping $b — it is an EPIC, not workable" >&2; continue ;; esac
    case "$st" in
      CLOSED)      done_n=$((done_n+1)); continue ;;
      IN_PROGRESS) busy="$busy $b"; continue ;;
    esac

    blocked=""
    # SOLO: unhandable while anything else is claimed.
    case " $SOLO " in *" $b "*)
      [ -n "$anybusy" ] && blocked="solo — waiting on$anybusy" ;;
    esac
    # SOLO in the other direction: if a solo bead is claimed, nothing else goes out.
    for s in $SOLO; do
      [ "$s" = "$b" ] && continue
      case " $anybusy " in *" $s "*) blocked="held — $s is running SOLO" ;; esac
    done
    # LAST: waits for every non-LAST bead in BATCH to close.
    case " $LAST " in *" $b "*)
      [ -n "$anyopen" ] && blocked="last — changes a gate; waiting on$anyopen" ;;
    esac
    # SEQ: child waits for parent to close.
    for pair in $SEQ; do
      child=${pair%%:*}; parent=${pair#*:}
      if [ "$child" = "$b" ] && [ "$(status_of "$parent")" != "CLOSED" ]; then
        blocked="sequential — needs $parent closed"
      fi
    done
    # EXCL: symmetric, shared files.
    for pair in $EXCL; do
      one=${pair%%:*}; two=${pair#*:}; other=""
      [ "$one" = "$b" ] && other="$two"
      [ "$two" = "$b" ] && other="$one"
      [ -z "$other" ] && continue
      case " $anybusy " in *" $other "*) blocked="shares files with $other, which is running" ;; esac
    done

    if [ -n "$blocked" ]; then held="$held $b"; printf "    %-14s %s\n" "$b" "$blocked" >&2; continue; fi
    todo="$todo $b"
    [ -z "$TARGET" ] && TARGET="$b"
  done
  echo "batch: $done_n done,$(echo $todo | wc -w | tr -d ' ') handable,$(echo $held | wc -w | tr -d ' ') held,$(echo $busy | wc -w | tr -d ' ') claimed" >&2
  [ -n "$busy" ] && echo "  in progress:$busy" >&2
  for b in $todo; do
    if [ "$b" = "$TARGET" ]; then printf "  > %s\n" "$b" >&2; else printf "    %s\n" "$b" >&2; fi
  done
  [ -z "$TARGET" ] && { echo "nothing handable right now — see the held/claimed lines above" >&2; exit 1; }
  echo >&2
fi

emit "$TARGET" | tee /dev/stderr | { command -v pbcopy >/dev/null && pbcopy && echo "--- copied $TARGET to clipboard ---" >&2; } || true
