@plot-pm/board 0.16.1 → 0.16.3

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.
@@ -88,6 +88,16 @@
88
88
  # within an hour of removing it at large scale.
89
89
  PLOT_WORKER_RECORD='\.plot-worker\.'
90
90
 
91
+ # `exclude_bundle_paths` (`plot-desk-dirt.sh`), SOURCED SO `plot_worker_dirty_filter`
92
+ # CAN EXCUSE A REBUILT BUNDLE THE SAME WAY `desk_dirt` DOES. A sibling path,
93
+ # the shape every caller of this file already sources it by (`$script_dir/` or
94
+ # `$(dirname "${BASH_SOURCE[0]}")/`), so the same path resolves it in turn.
95
+ # Best-effort: a guard reads `command -v exclude_bundle_paths` before any call,
96
+ # so a missing sibling degrades to the three exclusions below alone.
97
+ # shellcheck source=plot-desk-dirt.sh
98
+ desk_dirt_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-desk-dirt.sh"
99
+ [ -r "$desk_dirt_lib" ] && . "$desk_dirt_lib"
100
+
91
101
  # ---------------------------------------------------------------------------
92
102
  # THE REGISTRY HOLDS THE PID — the anchor moved from worktree to manifest
93
103
  # ---------------------------------------------------------------------------
@@ -105,31 +115,132 @@ PLOT_WORKER_RECORD='\.plot-worker\.'
105
115
  # its number. Without it, dead pids are one `fork()` away from reading `running`.
106
116
  #
107
117
  # THE WORKTREE→MANIFEST LOOKUP. The manifest directory lives at
108
- # `$PLOT_MANIFEST_DIR` when the caller sets it, or it is derived from the
109
- # worktree's repo root. Each manifest names a `worktree` field; the lookup
110
- # finds the manifest whose worktree matches.
118
+ # `$PLOT_MANIFEST_DIR` when the caller sets it, and is otherwise resolved from
119
+ # the MAIN CHECKOUT and the `Agent registry` key. Each manifest names a
120
+ # `worktree` field; the lookup finds the manifest whose worktree matches.
121
+ #
122
+ # THE RULE IS `deskManifest` / `manifestDirectory` IN THE DOMAIN
123
+ # (`packages/domain/src/rules/desk-manifest.ts`), and this is a DECLARED
124
+ # DUPLICATE of it rather than a call to it. `docs/shell-and-domain.md` puts the
125
+ # choice on the cost: `plot_manifest_for_worktree` runs once per worktree per
126
+ # fleet-scan pass, and a bundle answers in about 39 ms, so a hop here is paid by
127
+ # every desk on every pass forever. `packages/domain/corpus/desk-manifest.corpus.test.ts`
128
+ # holds the pair. NEITHER SIDE IS AUTHORITATIVE: on a disagreement the branch
129
+ # stops, and adjusting either side to make the comparison pass is forbidden.
111
130
 
112
- # The manifest directory, set by callers who know their repo root. When unset,
113
- # `plot_manifest_for_worktree` derives it from the worktree's own repo.
131
+ # The manifest directory, set by callers who know their repo root. When unset it
132
+ # is resolved once at source time, below.
114
133
  : "${PLOT_MANIFEST_DIR:=}"
115
134
 
135
+ # The MAIN checkout for a directory, or "" — the reading `plot_repo_root`
136
+ # (`plot-desk-root.sh:38-46`) makes, asked of a path rather than of the cwd.
137
+ #
138
+ # `--show-toplevel` ANSWERS THE DESK inside a linked worktree, which is the whole
139
+ # of #1086: this function derived `<desk>/.plot/agents` and found no manifest, so
140
+ # every worker-state reading taken from inside a dispatched desk read its agent
141
+ # as unregistered. Every linked worktree shares ONE common git dir, so the
142
+ # parent of `--git-common-dir` is the main checkout from anywhere.
143
+ #
144
+ # THE COMMON DIR MAY BE RELATIVE. In a linked worktree git prints an absolute
145
+ # path; in the main checkout it prints `.git`, relative to the tree. So it is
146
+ # resolved by `cd`-ing to the tree FIRST and then to the common dir, which makes
147
+ # both forms absolute, and `pwd -P` keeps it physical for the same reason
148
+ # `plot-desk-root.sh` does: `git worktree list` prints resolved paths, and a
149
+ # directory composed from a logical one (`/tmp` against `/private/tmp` on macOS)
150
+ # is a prefix no worktree path starts with.
151
+ plot_main_checkout_of() { # $1=directory → the main checkout, or "" (non-zero)
152
+ local at="$1" common root=''
153
+ [ -n "$at" ] && [ -d "$at" ] || return 1
154
+ common=$(git -C "$at" rev-parse --git-common-dir 2>/dev/null) && [ -n "$common" ] && {
155
+ common=$(cd -- "$at" 2>/dev/null && cd -- "$common" 2>/dev/null && pwd -P) || common=''
156
+ [ -n "$common" ] && root=$(dirname -- "$common")
157
+ }
158
+ [ -n "$root" ] || root=$(git -C "$at" rev-parse --show-toplevel 2>/dev/null) || return 1
159
+ [ -n "$root" ] || return 1
160
+ printf '%s' "$root"
161
+ }
162
+
163
+ # The `Agent registry` directory for a main checkout — `manifestDirectory`'s rule.
164
+ #
165
+ # An absolute value is taken as given, so a project may name a registry outside
166
+ # its own tree; a relative one joins to the MAIN CHECKOUT, never to a desk,
167
+ # because a desk must resolve the same directory the checkout does (`CLAUDE.md`
168
+ # gives that reason for `Board artifact` and `Agent settings`). An absent or
169
+ # empty key means `.plot/agents`. The trailing slash is trimmed, the way
170
+ # `plot-dispatch.sh:agent_registry_dir` trims one and `path.join` normalises
171
+ # one — two answers to *where is the registry* is what this removes.
172
+ # `PLOT_REPO_ROOT` IS PASSED AND NEVER INHERITED, which is this function's one
173
+ # trap. `plot-config.sh:222` takes an exported `PLOT_REPO_ROOT` in preference to
174
+ # asking git, and the fleet wrapper exports the DISPATCHING repository's root
175
+ # into every agent — so a lookup about a desk in another checkout read this
176
+ # repository's `CLAUDE.md`. Measured 2026-10-02 while building this slice: a
177
+ # fixture repo with its own `Agent registry` key answered the surrounding repo's
178
+ # `.plot/agents`. Its own fallback is `--show-toplevel`, which answers the DESK,
179
+ # so leaving the variable unset would reintroduce #1086 one layer down.
180
+ plot_manifest_dir_for() { # $1=main checkout → prints the directory
181
+ local root="$1" dir=''
182
+ if [ -x "$_plot_wstate_config" ] || [ -r "$_plot_wstate_config" ]; then
183
+ dir=$(PLOT_REPO_ROOT="$root" bash "$_plot_wstate_config" get "Agent registry" "" 2>/dev/null) || dir=''
184
+ fi
185
+ # Whitespace alone is a key nobody filled in.
186
+ dir=$(printf '%s' "$dir" | tr -d '[:space:]')
187
+ [ -n "$dir" ] || dir=".plot/agents"
188
+ case "$dir" in
189
+ /*) ;;
190
+ *) dir="${root%/}/$dir" ;;
191
+ esac
192
+ printf '%s' "${dir%/}"
193
+ }
194
+
195
+ # `plot-config.sh`, resolved ONCE at source time the way `plot-desk-root.sh:48`
196
+ # resolves its own bundle: reading `BASH_SOURCE[0]` inside a function reads the
197
+ # CALLER's file once the function has been exported.
198
+ _plot_wstate_config="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/plot-config.sh"
199
+
200
+ # THE DIRECTORY IS RESOLVED AT SOURCE TIME, AND THAT IS NOT A STYLE CHOICE.
201
+ # Both callers invoke the lookup as `manifest=$(plot_manifest_for_worktree …)`,
202
+ # so an assignment made inside it dies with the command substitution's subshell
203
+ # and a cache set there never reaches a second call. Resolving here spends one
204
+ # `plot-config.sh` fork per PROCESS instead of one per desk per pass.
205
+ #
206
+ # A CALLER-SET VALUE WINS and is never overwritten: the dispatcher and the tests
207
+ # set it, and they know their repo root without being asked.
208
+ if [ -z "$PLOT_MANIFEST_DIR" ]; then
209
+ _plot_wstate_root=$(plot_main_checkout_of "$PWD" 2>/dev/null) || _plot_wstate_root=''
210
+ if [ -n "$_plot_wstate_root" ]; then
211
+ PLOT_MANIFEST_DIR=$(plot_manifest_dir_for "$_plot_wstate_root")
212
+ fi
213
+ unset _plot_wstate_root
214
+ fi
215
+
116
216
  # Find the manifest for a worktree → the full path, or "" (non-zero).
117
217
  #
118
- # Iterates `.plot/agents/*.json` and matches on the `worktree` field. The
119
- # dispatcher records the RESOLVED worktree path (`realpath`), so the match is
120
- # tried against both the path as given and its realpath.
218
+ # Iterates the registry's `*.json` and matches on the `worktree` field. BOTH
219
+ # SIDES CARRY BOTH PATH FORMS: the dispatcher records the RESOLVED worktree path
220
+ # (`realpath`) and git may report either, and a desk registered by its symlinked
221
+ # path must still be found by its real one. So the manifest's field is resolved
222
+ # too, and either form matches either.
223
+ #
224
+ # SEVERAL MANIFESTS IS NOT THE FIRST MATCH. Two agents on one desk is an estate
225
+ # defect, and returning the first hides it, so this returns non-zero — the same
226
+ # answer `deskManifest` gives as `several`, which every caller reads as *no
227
+ # manifest*.
228
+ #
229
+ # ABSENT IS NOT FALSE: a missing directory, an unreadable manifest and a desk
230
+ # that is gone all answer "no manifest" rather than failing loudly.
121
231
  plot_manifest_for_worktree() { # $1=worktree → manifest path, or "" (non-zero)
122
- local wt="$1" dir real f wt_field
232
+ local wt="$1" dir real f wt_field wt_real found='' count=0
123
233
  [ -n "$wt" ] || return 1
124
234
 
125
- # Determine the manifest directory.
126
- if [ -n "$PLOT_MANIFEST_DIR" ]; then
127
- dir="$PLOT_MANIFEST_DIR"
128
- else
129
- # Derive from the worktree's repo. A worktree IS a git working tree, so
130
- # `git rev-parse --show-toplevel` from inside it returns the MAIN repo —
131
- # which is where `.plot/agents/` lives.
132
- dir=$(git -C "$wt" rev-parse --show-toplevel 2>/dev/null)/.plot/agents
235
+ # The directory: the caller's value, then the one resolved at source time, then
236
+ # a resolution from the WORKTREE's own main checkout — which covers a caller
237
+ # whose cwd is outside any repository.
238
+ dir="$PLOT_MANIFEST_DIR"
239
+ if [ -z "$dir" ]; then
240
+ local root
241
+ root=$(plot_main_checkout_of "$wt" 2>/dev/null) || root=''
242
+ [ -n "$root" ] || return 1
243
+ dir=$(plot_manifest_dir_for "$root")
133
244
  fi
134
245
  [ -d "$dir" ] || return 1
135
246
 
@@ -143,12 +254,19 @@ plot_manifest_for_worktree() { # $1=worktree → manifest path, or "" (non-zero)
143
254
  # per line, so a grep-and-sed approach avoids parsing JSON in bash.
144
255
  wt_field=$(grep -m1 '"worktree":' "$f" 2>/dev/null | sed 's/.*"worktree": *"\([^"]*\)".*/\1/')
145
256
  [ -n "$wt_field" ] || continue
146
- if [ "$wt_field" = "$wt" ] || [ "$wt_field" = "$real" ]; then
147
- printf '%s' "$f"
148
- return 0
257
+ # The manifest's own realpath, for the symlinked-registration case. A field
258
+ # naming a desk that is gone resolves to nothing and matches on its text.
259
+ wt_real=$(cd "$wt_field" 2>/dev/null && pwd -P) || wt_real=""
260
+ if [ "$wt_field" = "$wt" ] || [ "$wt_field" = "$real" ] ||
261
+ { [ -n "$wt_real" ] && { [ "$wt_real" = "$wt" ] || [ "$wt_real" = "$real" ]; }; }; then
262
+ found="$f"
263
+ count=$((count + 1))
149
264
  fi
150
265
  done
151
- return 1
266
+
267
+ # Exactly one, or nothing. `several` is named by the count, not by a pick.
268
+ [ "$count" = 1 ] || return 1
269
+ printf '%s' "$found"
152
270
  }
153
271
 
154
272
  # Read pid and startedAt from a manifest → "pid\tstartedAt", or "" (non-zero).
@@ -389,7 +507,29 @@ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
389
507
  plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers dropped
390
508
  local wt="$1"
391
509
  [ -n "$wt" ] && [ -d "$wt" ] || return 0
392
- plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
510
+ plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)" "$wt"
511
+ }
512
+
513
+ # Keep a file the estate writes into every desk out of `git status`.
514
+ #
515
+ # THROUGH THE CLONE'S `info/exclude`, never `.gitignore`: a `.gitignore` rule
516
+ # lives in the branch's own content, so a desk cut from an older branch would
517
+ # not see it, and `info/exclude` is per-repository and shared by every worktree.
518
+ # The directory and the file are CREATED when absent. Measured 2026-10-01
519
+ # (#1130): a clone with no `.git/info/exclude` kept `?? .metadata_never_index`
520
+ # in every free desk, and the AgentMonitor reported each waiting agent as
521
+ # `holds unlanded work`, because the writer skipped a file that did not exist.
522
+ #
523
+ # Best-effort: a desk that cannot take the line still works. Always returns 0.
524
+ plot_desk_exclude() { # $1=worktree $2=the exact line to exclude
525
+ local common excl
526
+ common=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || return 0
527
+ [ -n "$common" ] || return 0
528
+ case "$common" in /*) ;; *) common="$1/$common" ;; esac
529
+ excl="$common/info/exclude"
530
+ mkdir -p "$common/info" 2>/dev/null || return 0
531
+ grep -qxF "$2" "$excl" 2>/dev/null || printf '%s\n' "$2" >> "$excl" 2>/dev/null || true
532
+ return 0
393
533
  }
394
534
 
395
535
  # The same filter, over status output the CALLER already has.
@@ -406,17 +546,486 @@ plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers
406
546
  # what counts as work on the floor, two ways of getting the input to it — which
407
547
  # is the same one-computation-two-renderings split this file already draws for
408
548
  # `plot_worker_state`.
409
- plot_worker_dirty_filter() { # $1=`git status --porcelain` output → the real work
549
+ plot_worker_dirty_filter() { # $1=`git status --porcelain` output $2=worktree (optional) → the real work
550
+ local status="$1" wt="${2:-}"
551
+
552
+ # THE GENERATED BUNDLES ARE EXCUSED FIRST, WHILE THE LINE STILL CARRIES ITS
553
+ # XY PREFIX — `exclude_bundle_paths` reads column 4 on itself, the same cut
554
+ # the three exclusions below apply after it. Same rule as those three: `main`
555
+ # rebuilds and pushes every generated bundle (`bug/main-builds-its-bundles`,
556
+ # #1249), so a desk that rebuilt one locally to test holds nothing an agent
557
+ # put there. Only when `$wt` is given: the exclusion is read from THAT
558
+ # worktree's own `build.mjs`, and a caller holding only text with no
559
+ # worktree to read gets the three exclusions below alone, exactly as before
560
+ # this bundle existed.
561
+ if [ -n "$wt" ] && command -v exclude_bundle_paths >/dev/null 2>&1; then
562
+ status=$(printf '%s\n' "$status" | exclude_bundle_paths "$wt")
563
+ fi
564
+
410
565
  # `--porcelain` is the STABLE format; `git status` prose is localised and
411
566
  # reflows. Cut at column 4: the first three bytes are the XY status pair and a
412
567
  # space, and a filename can contain spaces of its own.
413
- printf '%s' "$1" \
568
+ printf '%s' "$status" \
414
569
  | cut -c4- \
415
570
  | grep -vE "(^|/)$PLOT_WORKER_RECORD" \
416
571
  | grep -vE "$PLOT_EDITOR_LEFTOVER" \
417
572
  | grep -vE "$PLOT_TOOL_SCRATCH" || true
418
573
  }
419
574
 
575
+ # ---------------------------------------------------------------------------
576
+ # THE ONE-SAMPLE `idle` RULE — the shell's half of a declared duplicate
577
+ # ---------------------------------------------------------------------------
578
+ #
579
+ # A DECLARED DUPLICATE OF `idleNow`, and the pair is held by
580
+ # `packages/domain/corpus/sample.corpus.test.ts`. `docs/shell-and-domain.md` §1
581
+ # settles which side of the cost rule this falls on: the rule is asked once per
582
+ # agent per pass, so a 39 ms `node` hop is paid by every agent on this machine
583
+ # forever. Neither side is authoritative — on a disagreement the branch stops,
584
+ # and adjusting either side to make the comparison pass is the one move
585
+ # forbidden.
586
+ #
587
+ # WHY IT LIVES HERE RATHER THAN IN THE MONITOR. Two callers read it: the
588
+ # WorkerMonitor sources this file today, and the loop's own watcher sources it
589
+ # too. One function, two readers, one answer — the same split this file was
590
+ # extracted to hold.
591
+
592
+ # Seconds since the newest thing in a desk's tree changed.
593
+ #
594
+ # THIS IS WHAT REPLACED A COMPARISON BETWEEN TWO PASSES. The two-sample rule
595
+ # asked *did the fingerprint change between pass N-1 and pass N*, which needs a
596
+ # process to hold pass N-1. This asks *how long since anything moved*, which the
597
+ # filesystem has been recording all along — and `at least the window` is a
598
+ # stronger statement than `unchanged across two passes 30 s apart`.
599
+ #
600
+ # THREE SOURCES, AND THE NEWEST OF THEM WINS:
601
+ #
602
+ # HEAD's committer time an agent that commits has plainly done something
603
+ # each dirty path's mtime an agent editing a file
604
+ # each dirty path's PARENT an add, a removal or a rename, which does not
605
+ # move any surviving file's own mtime
606
+ #
607
+ # THE DESK ROOT'S OWN MTIME IS NEVER READ, and that is the reading's one
608
+ # deliberate blind spot. The loop writes `.plot-worker.*` records into the desk
609
+ # root and replaces them (`plot-dispatch.sh`'s manifest `mv`), which moves the
610
+ # root directory's mtime. `plot_worker_dirty_filter` drops those records from
611
+ # the list, but a dirty path AT the root would still contribute its parent — the
612
+ # root — and the loop's own bookkeeping would then read as tree activity, so
613
+ # `idle` could never fire. A parent counts only BELOW the root; a root-level
614
+ # dirty path contributes its own mtime instead. The stated cost: a removal or a
615
+ # rename at the desk root alone does not move this number.
616
+ #
617
+ # `unreadable` WHERE THERE IS NO TREE TO READ, and that word travels to the
618
+ # verdict rather than being collapsed into a number. A failure to observe is not
619
+ # evidence of something to see; zero would read as *everything just moved* and
620
+ # a huge number as *nothing has moved in years*, and both are inventions.
621
+ plot_worker_tree_quiet_seconds() { # $1=worktree → seconds | unreadable
622
+ local wt="$1" status paths head_ct now newest
623
+ [ -n "$wt" ] && [ -d "$wt" ] || { printf 'unreadable'; return 0; }
624
+
625
+ # HEAD's committer time, which is the whole reading on a clean tree. A repo
626
+ # with no commit yet answers nothing and leaves the dirty paths to speak.
627
+ head_ct=$(git -C "$wt" log -1 --format=%ct 2>/dev/null)
628
+ case "$head_ct" in ''|*[!0-9]*) head_ct='' ;; esac
629
+
630
+ # `-uall` IS FOR THIS READING ONLY, AND IT IS A MEASUREMENT RATHER THAN A
631
+ # TIDINESS. Default porcelain collapses a wholly new untracked directory to
632
+ # one line, `?? brandnew/`, because once git knows the whole directory is
633
+ # untracked it stops descending — fine for a display, fatal for an mtime. A
634
+ # directory's mtime moves when an ENTRY is added or removed and not when a
635
+ # file inside it is written, so a file an agent is editing right now inside a
636
+ # directory it created earlier reads as untouched. Measured 2026-10-02: a
637
+ # directory aged 2 000 s holding a file 1 s old read `tree quiet: 2001`, which
638
+ # is a false `idle` on an agent mid-edit. `-uall` lists `brandnew/f.txt`, so
639
+ # the file's own mtime is read and its parent is read beside it.
640
+ #
641
+ # THE FINGERPRINT KEEPS THE DEFAULT, and the asymmetry is correct: it asked
642
+ # *did these path NAMES change*, and a collapsed directory's name changes when
643
+ # the directory appears. This asks *when did anything move*, which the name
644
+ # cannot answer. Recorded in the plan's Open Points rather than widened
645
+ # silently.
646
+ status=$(git -C "$wt" status --porcelain -uall 2>/dev/null)
647
+
648
+ # THE SAME FILTER THE FINGERPRINT USED, for the same reason: this script's own
649
+ # records and the monitor's findings file are not work an agent left, and a
650
+ # raw status would make the monitor watch itself.
651
+ paths=''
652
+ if command -v plot_worker_dirty_filter >/dev/null 2>&1; then
653
+ paths=$(plot_worker_dirty_filter "$status")
654
+ else
655
+ paths=$(printf '%s' "$status" | cut -c4-)
656
+ fi
657
+
658
+ # Every path to stat, one per line, absolute. Built in awk rather than a bash
659
+ # loop so a desk holding hundreds of dirty paths costs one fork.
660
+ #
661
+ # A RENAME ARRIVES AS `old -> new` and the NEW name is the one that exists.
662
+ # A path with unusual bytes arrives quoted by git; the quotes are stripped so
663
+ # `stat` sees the name, which is lossy for a true embedded quote and the
664
+ # alternative is parsing C escapes in awk.
665
+ #
666
+ # A DELETED PATH DOES NOT EXIST, so its own mtime is unreadable. It is still
667
+ # listed — `stat` simply says nothing for it — and its parent below the root
668
+ # carries the change, which is exactly what a removal moves.
669
+ local list
670
+ list=$(printf '%s\n' "$paths" | awk -v wt="$wt" '
671
+ { line = $0 }
672
+ line == "" { next }
673
+ # A rename: take the destination, which is the name on disk now.
674
+ {
675
+ i = index(line, " -> ")
676
+ if (i > 0) line = substr(line, i + 4)
677
+ # Git quotes a path holding unusual bytes. Drop the quotes; the escapes
678
+ # inside are left as they are, and such a path simply reads unreadable.
679
+ if (substr(line, 1, 1) == "\"" && substr(line, length(line), 1) == "\"")
680
+ line = substr(line, 2, length(line) - 2)
681
+ if (line == "") next
682
+ print wt "/" line
683
+ # THE PARENT, BUT ONLY BELOW THE ROOT. A path with no `/` in it sits at
684
+ # the desk root, and the root is the directory the loop keeps touching.
685
+ if (index(line, "/") > 0) {
686
+ n = line
687
+ sub(/\/[^\/]*$/, "", n)
688
+ if (n != "" && n != ".") print wt "/" n
689
+ }
690
+ }
691
+ ')
692
+
693
+ # ONE `stat` CALL OVER EVERY PATH, and the flavour is probed once against a
694
+ # directory that certainly exists.
695
+ #
696
+ # THE ORDER IS LOAD-BEARING AND CI MEASURED WHY. On Linux `stat -f` is not an
697
+ # unknown flag — it means FILESYSTEM info and it SUCCEEDS, printing
698
+ # `Namelen: 255 Type: ext2/ext3`, which a caller then subtracts from a clock
699
+ # (`plot-fleetctl.sh`, and two tests that passed on macOS). So GNU's own form
700
+ # is asked first, because GNU is the implementation that mis-parses the other's
701
+ # flag, and each answer is validated as digits rather than trusted.
702
+ newest="$head_ct"
703
+ if [ -n "$list" ]; then
704
+ local fmt='' mtimes
705
+ if [ -n "$(stat -c %Y "$wt" 2>/dev/null)" ]; then fmt='gnu'
706
+ elif [ -n "$(stat -f %m "$wt" 2>/dev/null)" ]; then fmt='bsd'
707
+ fi
708
+ if [ -n "$fmt" ]; then
709
+ # A missing path makes `stat` exit non-zero while still printing the
710
+ # others, so the exit code is deliberately not read.
711
+ if [ "$fmt" = 'gnu' ]; then
712
+ mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -c %Y 2>/dev/null)
713
+ else
714
+ mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -f %m 2>/dev/null)
715
+ fi
716
+ # The maximum, taken in awk: only lines that are entirely digits count, so
717
+ # a filesystem report or an error line cannot become a timestamp.
718
+ local max
719
+ max=$(printf '%s\n' "$mtimes" | awk '/^[0-9]+$/ { if ($0 > m) m = $0 } END { if (m != "") print m }')
720
+ if [ -n "$max" ]; then
721
+ if [ -z "$newest" ] || [ "$max" -gt "$newest" ] 2>/dev/null; then newest="$max"; fi
722
+ fi
723
+ fi
724
+ fi
725
+
726
+ # NO COMMIT AND NO READABLE PATH is no reading at all — a desk whose git
727
+ # directory cannot be read, or a worktree with no history yet and nothing on
728
+ # the floor. It is not a very long silence.
729
+ [ -n "$newest" ] || { printf 'unreadable'; return 0; }
730
+
731
+ now=$(date +%s)
732
+ local quiet=$(( now - newest ))
733
+ # A time in the future — clock skew across a mounted volume — reads as zero
734
+ # rather than negative. A comparison against a window would behave correctly
735
+ # by accident here and not elsewhere; clamping says what is meant.
736
+ [ "$quiet" -lt 0 ] && quiet=0
737
+ printf '%s' "$quiet"
738
+ }
739
+
740
+ # Is this desk idle, from ONE reading of it?
741
+ #
742
+ # THE SHELL'S COPY OF `idleNow`, argument for argument. Six readings in, one
743
+ # word out:
744
+ #
745
+ # $1 pid alive | dead | unrecorded
746
+ # $2 spoken 1 spoken | 0 not (a reading, never an absence)
747
+ # $3 silence seconds since the newest transcript line, or any non-number
748
+ # $4 activity the sampler's word: `working` vetoes, `idle` and `` do not
749
+ # $5 treeQuiet seconds since the newest tree change, or any non-number
750
+ # $6 commits yes | no | unanswerable
751
+ # $7 window seconds a duration must reach
752
+ #
753
+ # ONE WORD ON STDOUT AND EXIT 0, ALWAYS. A caller that tests only for empty
754
+ # output cannot tell `silent` from *the function was missing*, so read the exit
755
+ # code: this prints `idle` or `silent` and returns 0, and a shell that never
756
+ # sourced this file returns 127 having printed nothing.
757
+ #
758
+ # `≥ window`, NOT `>`. The window is where the question becomes worth asking, so
759
+ # a desk exactly at it is eligible — the same boundary `quiet -lt window → busy`
760
+ # draws from the other side.
761
+ #
762
+ # THE CPU IS A VETO AND NOT THE VERDICT. Only `working` refuses, because only
763
+ # `working` says something is running. `idle` (a frozen subtree clock) and ``
764
+ # (no child holding a clock at all) agree here: past the window, each is an
765
+ # agent that has stopped. That is the opposite of how the old CPU-snapshot rule
766
+ # read the empty answer, and deliberately so — this line is reached only after
767
+ # the window has already elapsed.
768
+ #
769
+ # NO `gone` ARM. A dead pid answers `silent`: the wrapper that starts the agent
770
+ # knows the instant it ends and publishes `gone` itself.
771
+ #
772
+ # AN UNREADABLE VALUE ANSWERS `silent`, every one of them. An `unrecorded` pid,
773
+ # an `unavailable` transcript, an `unreadable` tree and an `unanswerable` commit
774
+ # question each withhold the finding, because a failure to observe is not
775
+ # evidence of something to see.
776
+ plot_worker_idle_now() { # $1..$7 as above → idle | silent
777
+ local pid="$1" spoken="$2" silence="$3" activity="$4" tree="$5" commits="$6" window="$7"
778
+
779
+ [ "$pid" = 'alive' ] || { printf 'silent'; return 0; }
780
+ [ "$spoken" = '1' ] || { printf 'silent'; return 0; }
781
+
782
+ # A non-numeric duration is a reading that was not taken. `unavailable`,
783
+ # `unreadable` and an empty string all land here, and so would a filesystem
784
+ # report that slipped past the validation above.
785
+ case "$window" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
786
+ case "$silence" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
787
+ [ "$silence" -ge "$window" ] || { printf 'silent'; return 0; }
788
+
789
+ [ "$activity" = 'working' ] && { printf 'silent'; return 0; }
790
+
791
+ case "$tree" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
792
+ [ "$tree" -ge "$window" ] || { printf 'silent'; return 0; }
793
+
794
+ [ "$commits" = 'yes' ] || { printf 'silent'; return 0; }
795
+ printf 'idle'
796
+ return 0
797
+ }
798
+
799
+ # ---------------------------------------------------------------------------
800
+ # THE WATCHER'S OWN PASS — the readings `plot_worker_idle_now` is asked about
801
+ # ---------------------------------------------------------------------------
802
+ #
803
+ # MOVED FROM `plot-worker-monitor.sh` RATHER THAN REWRITTEN. The WorkerMonitor
804
+ # process is gone (`bug/the-loop-reports-idle`): the loop's own watcher
805
+ # subshell calls these instead, so the readings move to the file the loop
806
+ # already sources rather than living in a script that no longer runs.
807
+ #
808
+ # `monitor_has_commits` IS MOVED, NOT CHANGED. The scope guard that made it
809
+ # untouchable in wave 1 still holds — the `-- .` pathspec and the
810
+ # `origin/<default>` resolution are copied verbatim, including the comment
811
+ # that explains why a dispatched branch's own claim commit must not count.
812
+
813
+ # The reset epoch a desk is waiting out, or nothing.
814
+ #
815
+ # THE LOOP WRITES THE FILE AND THIS ONLY READS IT. `.plot-worker.limited`
816
+ # carries the reset epoch, the same instant as ISO text, and the limit line;
817
+ # only the first field is read here, because the caller compares integers and
818
+ # never parses a date.
819
+ #
820
+ # NOTHING IS ANSWERED FOR AN ABSENT, EMPTY OR UNPARSEABLE FILE, and the caller
821
+ # reads that as *this desk is not waiting*. A record whose first field is not a
822
+ # number is the same answer as no record: a reading that cannot be made must
823
+ # not widen into a reason to hold a verdict back.
824
+ plot_worker_limited_reset() { # $1=worktree → epoch seconds | ""
825
+ local file="$1/.plot-worker.limited" reset
826
+ [ -n "$1" ] && [ -r "$file" ] || return 0
827
+ reset=$(cut -f1 < "$file" 2>/dev/null | head -n1)
828
+ case "$reset" in (''|*[!0-9]*) return 0 ;; esac
829
+ printf '%s' "$reset"
830
+ }
831
+
832
+ # Has THIS worker's conversation written yet? → 0 spoken | 1 unspoken | 2 no handle
833
+ #
834
+ # THE DESK-WIDE NUMBER CANNOT SAY. After a hop to a new branch the loop mints a
835
+ # fresh handle, and the new conversation has no transcript file until its first
836
+ # line. Until then the desk's newest file is the PREVIOUS slice's, and its
837
+ # silence is not this worker's. So the watcher asks the loop's own probe with
838
+ # the loop's own handle: one probe, two readers, one answer.
839
+ #
840
+ # THREE ANSWERS, AND THE THIRD IS NOT THE SECOND. `0` the handle's file exists,
841
+ # `1` it does not, `2` there is no handle to ask about. `plot_transcript_exists`
842
+ # reads *no handle* as *no file*, which suits `session_flag`; here it would
843
+ # make a hand-started watcher read every quiet worker as unspoken and disable
844
+ # `idle` silently (#1074). So the handle is checked here, before the probe.
845
+ plot_worker_conversation_spoken() { # $1=worktree → 0 spoken | 1 unspoken | 2 no handle
846
+ command -v session_handle >/dev/null 2>&1 || return 2
847
+ command -v plot_transcript_exists >/dev/null 2>&1 || return 2
848
+ local handle
849
+ handle=$(session_handle) || return 2
850
+ [ -n "$handle" ] || return 2
851
+ plot_transcript_exists "$1" "$handle" && return 0
852
+ return 1
853
+ }
854
+
855
+ # Are there commits on this branch yet? → 0 yes | 1 no | 2 unanswerable
856
+ #
857
+ # THE THIRD CONDITION ON `idle`, and the one that separates a stall from an
858
+ # agent still thinking about a hard first slice.
859
+ #
860
+ # COUNTED AGAINST THE LOCAL `origin/<default>` REF — never a fetch, because
861
+ # this reading makes no network call. And when there is no such ref the
862
+ # question is UNANSWERABLE, so this returns 2 and `idle` does not fire:
863
+ # counting against nothing would count the whole history from the root commit
864
+ # and read every branch in a remote-less repo as having committed.
865
+ plot_worker_has_commits() { # $1=worktree → 0 yes | 1 no | 2 unanswerable
866
+ [ -n "$1" ] && [ -d "$1" ] || return 2
867
+ local wt="$1" base n
868
+ base=$(git -C "$wt" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
869
+ [ -n "$base" ] || { git -C "$wt" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
870
+ [ -n "$base" ] || return 2
871
+ # COUNT THE AGENT'S WORK, NOT THE BRANCH'S COMMITS. `plot-dispatch.sh` writes
872
+ # `commit --allow-empty -m "plot: claim <branch>"` BEFORE the agent starts, so
873
+ # `$base..HEAD` is never zero on a dispatched branch and this condition could
874
+ # never refuse an `idle`. Measured 2026-08-30 (#538 red in CI): a worker
875
+ # burning CPU in `yes > /dev/null` was reported idle, because the one
876
+ # condition that could have saved it was satisfied by bookkeeping the agent
877
+ # did not do.
878
+ #
879
+ # The `-- .` pathspec is what does it: `rev-list` with a pathspec keeps only
880
+ # commits that TOUCHED A FILE, and the claim is empty by construction
881
+ # (`--allow-empty`). That is a property rather than a message match — a claim
882
+ # whose wording changes still reads as empty, and an agent committing an
883
+ # empty marker of its own is correctly not counted as work either.
884
+ n=$(git -C "$wt" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
885
+ case "$n" in ''|*[!0-9]*) return 2 ;; esac
886
+ [ "$n" -gt 0 ] && return 0
887
+ return 1
888
+ }
889
+
890
+ json_escape() { # $1 = raw → prints a JSON-safe string body
891
+ printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
892
+ || printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
893
+ }
894
+
895
+ # Append one finding line to a desk's findings file, in the WorkerMonitor's own
896
+ # shape — the board's reader keys on this exact field set and this exact
897
+ # monitor name, and changing either would make an unbroken channel look broken.
898
+ #
899
+ # `since` AND `measuredAt` ARE DIFFERENT TIMES. `measuredAt` is when this
900
+ # reading was taken; `since` is when the finding first held. A finding that has
901
+ # held for twenty minutes and one taken twenty minutes ago are not the same
902
+ # fact, and an operator triaging a board needs the first.
903
+ plot_worker_publish_finding() { # $1=file $2=branch $3=worktree $4=finding $5=evidence $6=since
904
+ local file="$1" branch="$2" worktree="$3" finding="$4" evidence="$5" since="$6" now line
905
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
906
+ line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
907
+ 'WorkerMonitor' \
908
+ "$(json_escape "$branch")" \
909
+ "$(json_escape "$worktree")" \
910
+ "$(json_escape "$finding")" \
911
+ "${since:-$now}" \
912
+ "$(json_escape "$evidence")" \
913
+ "$now")
914
+ [ -n "$file" ] && printf '%s\n' "$line" >> "$file" 2>/dev/null
915
+ printf 'plot-watch %s\n' "$line"
916
+ }
917
+
918
+ # ONE PASS OF THE LOOP'S OWN WATCHER: take the six readings, ask
919
+ # `plot_worker_idle_now`, publish only on a change.
920
+ #
921
+ # THE PID IS ALWAYS `alive`. The caller is the loop's watcher subshell, started
922
+ # with the loop's own pid (`$_watch_loop_pid`); that pid is alive by
923
+ # construction for as long as the watcher runs, so there is no `monitor_pid_alive`
924
+ # reading here the way the old monitor needed one for a SEPARATE process it was
925
+ # watching.
926
+ #
927
+ # `$5` IS THE RACE THE PLAN DID NOT ANTICIPATE. A prompt started into a
928
+ # conversation whose transcript is already older than the window could read
929
+ # `idle` on its FIRST pass and be ended before the model ever answers — the
930
+ # clamp mirrors the usage-limit clamp's own shape: `silenceSeconds` is capped at
931
+ # the seconds the prompt has actually been running, so a desk cannot be judged
932
+ # quiet for longer than this prompt has existed.
933
+ #
934
+ # THE CHILD IS SAMPLED ON THE PID GIVEN, NEVER ON `$_watch_loop_pid` ITSELF.
935
+ # `plot_worker_activity` sums the pid's whole descendant subtree; the loop's own
936
+ # pid is the root the agent CLI hangs off, so the caller passes it explicitly
937
+ # rather than this function assuming which pid names the subject.
938
+ #
939
+ # PUBLISH AND SIGNAL ARE SEPARATE. This function only ever publishes; it is the
940
+ # caller's job to decide whether the PUBLISHED finding may end the worker — the
941
+ # flag gates that decision, not this one.
942
+ plot_worker_idle_watch_pass() { # $1=worktree $2=branch $3=findings-file $4=window $5=prompt_started_at $6=pid → exit 0 idle | 1 silent (also publishes)
943
+ local wt="$1" branch="$2" file="$3" window="$4" started_at="$5" pid="$6"
944
+ local silence spoken_rc spoken tree commits_rc commits activity finding evidence verdict
945
+
946
+ silence=$(plot_transcript_quiet_seconds "$wt" 2>/dev/null)
947
+ case "$silence" in
948
+ ''|unavailable|*[!0-9]*) silence='' ;;
949
+ esac
950
+
951
+ # THE USAGE-LIMIT CLAMP, AHEAD OF THE WINDOW CHECK. Silence is measured from
952
+ # the later of the newest transcript line and the reset this desk waits for;
953
+ # while the reset is ahead of now the difference is negative, which clamps to
954
+ # 0 and reads as busy — the agent is doing exactly what it should.
955
+ local limited_until
956
+ limited_until=$(plot_worker_limited_reset "$wt")
957
+ if [ -n "$limited_until" ] && [ -n "$silence" ]; then
958
+ local since_reset
959
+ since_reset=$(( $(date +%s) - limited_until ))
960
+ [ "$since_reset" -lt 0 ] && since_reset=0
961
+ [ "$since_reset" -lt "$silence" ] && silence=$since_reset
962
+ fi
963
+
964
+ # THE RACE CLAMP. A prompt's transcript may be older than the window on the
965
+ # very first pass, because it is the PREVIOUS slice's silence, not this
966
+ # prompt's. Silence can never exceed how long this prompt has actually run.
967
+ case "$started_at" in
968
+ ''|*[!0-9]*) ;;
969
+ *)
970
+ local ran
971
+ ran=$(( $(date +%s) - started_at ))
972
+ [ "$ran" -lt 0 ] && ran=0
973
+ if [ -n "$silence" ] && [ "$ran" -lt "$silence" ]; then silence=$ran; fi
974
+ ;;
975
+ esac
976
+
977
+ plot_worker_conversation_spoken "$wt"; spoken_rc=$?
978
+ case "$spoken_rc" in
979
+ 0) spoken=1 ;;
980
+ *) spoken=0 ;;
981
+ esac
982
+
983
+ activity=$(plot_worker_activity "$pid" 2>/dev/null)
984
+
985
+ tree=$(plot_worker_tree_quiet_seconds "$wt" 2>/dev/null)
986
+
987
+ plot_worker_has_commits "$wt"; commits_rc=$?
988
+ case "$commits_rc" in
989
+ 0) commits='yes' ;;
990
+ 1) commits='no' ;;
991
+ *) commits='unanswerable' ;;
992
+ esac
993
+
994
+ verdict=$(plot_worker_idle_now 'alive' "$spoken" "${silence:-unavailable}" "$activity" "$tree" "$commits" "$window")
995
+
996
+ finding=''
997
+ evidence=''
998
+ if [ "$verdict" = 'idle' ]; then
999
+ finding='idle'
1000
+ evidence="the agent's transcript has been silent for over ${window}s with no child process burning CPU behind it, nothing in its tree has moved for ${tree}s, and the branch already carries commits"
1001
+ fi
1002
+
1003
+ # PUBLISH ONLY ON A CHANGE, held in a variable of the CALLER's subshell —
1004
+ # named `PLOT_WATCH_PUBLISHED`/`PLOT_WATCH_SINCE` rather than local, because
1005
+ # this function is called repeatedly from the watcher's own `while` loop and
1006
+ # the state must survive between calls the way `monitor_pass`'s did.
1007
+ if [ "$finding" != "${PLOT_WATCH_PUBLISHED:-}" ]; then
1008
+ local now_iso
1009
+ now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1010
+ if [ -n "$finding" ]; then
1011
+ PLOT_WATCH_SINCE="$now_iso"
1012
+ plot_worker_publish_finding "$file" "$branch" "$wt" "$finding" "$evidence" "$PLOT_WATCH_SINCE"
1013
+ elif [ -n "${PLOT_WATCH_PUBLISHED:-}" ]; then
1014
+ PLOT_WATCH_SINCE="$now_iso"
1015
+ plot_worker_publish_finding "$file" "$branch" "$wt" 'clear' \
1016
+ "the ${PLOT_WATCH_PUBLISHED} finding no longer holds; the worker is measuring healthy again" "$PLOT_WATCH_SINCE"
1017
+ fi
1018
+ PLOT_WATCH_PUBLISHED="$finding"
1019
+ fi
1020
+
1021
+ # THE VERDICT IS THE EXIT CODE, NOT STDOUT. Stdout is reserved for the
1022
+ # publish line alone (`plot_worker_publish_finding`'s own `plot-watch …`
1023
+ # line), the same convention the old monitor's process stdout carried into
1024
+ # `.plot-worker.log` beside the agent's own output. A caller that needs the
1025
+ # word reads the exit code: 0 for `idle`, 1 for anything else.
1026
+ [ "$finding" = 'idle' ]
1027
+ }
1028
+
420
1029
  # The total CPU time, in centiseconds, of a pid and every process descended from
421
1030
  # it. Prints the number; prints `0` and returns non-zero when the pid names no
422
1031
  # live process at all.