@plot-pm/board 0.16.2 → 0.17.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.
@@ -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).
@@ -381,6 +499,15 @@ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
381
499
  # exact population this state must not name. Excluding them is not widening the
382
500
  # rule; it is the `.tmp1` case again, for files Plot itself dropped there.
383
501
  #
502
+ # NOR IS AN UNTRACKED ROOT `PLOT-CORRECTION.md`. `plot-worker-loop.sh`'s
503
+ # `write_correction` writes it untracked into the desk for the agent to read,
504
+ # and `reset_desk` removes it when the desk takes the next slice. A desk whose
505
+ # only other content is that file holds no unlanded work, so the loop waits for
506
+ # the checks rather than ending `holding-work`. The match is the whole
507
+ # porcelain line `?? PLOT-CORRECTION.md`, the line `plot-desk-dirt.sh`'s
508
+ # `desk_dirt` drops: a `docs/PLOT-CORRECTION.md`, or a staged, modified or
509
+ # deleted copy, is content and counts.
510
+ #
384
511
  # THE EXCLUSION STAYS NARROW OTHERWISE, by suffix and by Plot's own filenames.
385
512
  # An uncommitted source file is precisely the case this detection exists for, so
386
513
  # anything broader — "untracked files do not count", "only tracked changes
@@ -389,7 +516,7 @@ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
389
516
  plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers dropped
390
517
  local wt="$1"
391
518
  [ -n "$wt" ] && [ -d "$wt" ] || return 0
392
- plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
519
+ plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)" "$wt"
393
520
  }
394
521
 
395
522
  # Keep a file the estate writes into every desk out of `git status`.
@@ -428,17 +555,324 @@ plot_desk_exclude() { # $1=worktree $2=the exact line to exclude
428
555
  # what counts as work on the floor, two ways of getting the input to it — which
429
556
  # is the same one-computation-two-renderings split this file already draws for
430
557
  # `plot_worker_state`.
431
- plot_worker_dirty_filter() { # $1=`git status --porcelain` output → the real work
558
+ plot_worker_dirty_filter() { # $1=`git status --porcelain` output $2=worktree (optional) → the real work
559
+ local status="$1" wt="${2:-}"
560
+
561
+ # THE GENERATED BUNDLES ARE EXCUSED FIRST, WHILE THE LINE STILL CARRIES ITS
562
+ # XY PREFIX — `exclude_bundle_paths` reads column 4 on itself, the same cut
563
+ # the three exclusions below apply after it. Same rule as those three: `main`
564
+ # rebuilds and pushes every generated bundle (`bug/main-builds-its-bundles`,
565
+ # #1249), so a desk that rebuilt one locally to test holds nothing an agent
566
+ # put there. Only when `$wt` is given: the exclusion is read from THAT
567
+ # worktree's own `build.mjs`, and a caller holding only text with no
568
+ # worktree to read gets the three exclusions below alone, exactly as before
569
+ # this bundle existed.
570
+ if [ -n "$wt" ] && command -v exclude_bundle_paths >/dev/null 2>&1; then
571
+ status=$(printf '%s\n' "$status" | exclude_bundle_paths "$wt")
572
+ fi
573
+
432
574
  # `--porcelain` is the STABLE format; `git status` prose is localised and
433
575
  # reflows. Cut at column 4: the first three bytes are the XY status pair and a
434
576
  # space, and a filename can contain spaces of its own.
435
- printf '%s' "$1" \
577
+ printf '%s' "$status" | grep -vxF '?? PLOT-CORRECTION.md' \
436
578
  | cut -c4- \
437
579
  | grep -vE "(^|/)$PLOT_WORKER_RECORD" \
438
580
  | grep -vE "$PLOT_EDITOR_LEFTOVER" \
439
581
  | grep -vE "$PLOT_TOOL_SCRATCH" || true
440
582
  }
441
583
 
584
+ # ---------------------------------------------------------------------------
585
+ # THE ONE-SAMPLE `idle` RULE — the shell's half of a declared duplicate
586
+ # ---------------------------------------------------------------------------
587
+ #
588
+ # A DECLARED DUPLICATE OF `idleNow`, and the pair is held by
589
+ # `packages/domain/corpus/sample.corpus.test.ts`. `docs/shell-and-domain.md` §1
590
+ # settles which side of the cost rule this falls on: the rule is asked once per
591
+ # agent per pass, so a 39 ms `node` hop is paid by every agent on this machine
592
+ # forever. Neither side is authoritative — on a disagreement the branch stops,
593
+ # and adjusting either side to make the comparison pass is the one move
594
+ # forbidden.
595
+ #
596
+ # WHY IT LIVES HERE RATHER THAN IN THE MONITOR. Two callers read it: the
597
+ # WorkerMonitor sources this file today, and the loop's own watcher sources it
598
+ # too. One function, two readers, one answer — the same split this file was
599
+ # extracted to hold.
600
+
601
+ # Seconds since the newest thing in a desk's tree changed.
602
+ #
603
+ # THIS IS WHAT REPLACED A COMPARISON BETWEEN TWO PASSES. The two-sample rule
604
+ # asked *did the fingerprint change between pass N-1 and pass N*, which needs a
605
+ # process to hold pass N-1. This asks *how long since anything moved*, which the
606
+ # filesystem has been recording all along — and `at least the window` is a
607
+ # stronger statement than `unchanged across two passes 30 s apart`.
608
+ #
609
+ # THREE SOURCES, AND THE NEWEST OF THEM WINS:
610
+ #
611
+ # HEAD's committer time an agent that commits has plainly done something
612
+ # each dirty path's mtime an agent editing a file
613
+ # each dirty path's PARENT an add, a removal or a rename, which does not
614
+ # move any surviving file's own mtime
615
+ #
616
+ # THE DESK ROOT'S OWN MTIME IS NEVER READ, and that is the reading's one
617
+ # deliberate blind spot. The loop writes `.plot-worker.*` records into the desk
618
+ # root and replaces them (`plot-dispatch.sh`'s manifest `mv`), which moves the
619
+ # root directory's mtime. `plot_worker_dirty_filter` drops those records from
620
+ # the list, but a dirty path AT the root would still contribute its parent — the
621
+ # root — and the loop's own bookkeeping would then read as tree activity, so
622
+ # `idle` could never fire. A parent counts only BELOW the root; a root-level
623
+ # dirty path contributes its own mtime instead. The stated cost: a removal or a
624
+ # rename at the desk root alone does not move this number.
625
+ #
626
+ # `unreadable` WHERE THERE IS NO TREE TO READ, and that word travels to the
627
+ # verdict rather than being collapsed into a number. A failure to observe is not
628
+ # evidence of something to see; zero would read as *everything just moved* and
629
+ # a huge number as *nothing has moved in years*, and both are inventions.
630
+ plot_worker_tree_quiet_seconds() { # $1=worktree → seconds | unreadable
631
+ local wt="$1" status paths head_ct now newest
632
+ [ -n "$wt" ] && [ -d "$wt" ] || { printf 'unreadable'; return 0; }
633
+
634
+ # HEAD's committer time, which is the whole reading on a clean tree. A repo
635
+ # with no commit yet answers nothing and leaves the dirty paths to speak.
636
+ head_ct=$(git -C "$wt" log -1 --format=%ct 2>/dev/null)
637
+ case "$head_ct" in ''|*[!0-9]*) head_ct='' ;; esac
638
+
639
+ # `-uall` IS FOR THIS READING ONLY, AND IT IS A MEASUREMENT RATHER THAN A
640
+ # TIDINESS. Default porcelain collapses a wholly new untracked directory to
641
+ # one line, `?? brandnew/`, because once git knows the whole directory is
642
+ # untracked it stops descending — fine for a display, fatal for an mtime. A
643
+ # directory's mtime moves when an ENTRY is added or removed and not when a
644
+ # file inside it is written, so a file an agent is editing right now inside a
645
+ # directory it created earlier reads as untouched. Measured 2026-10-02: a
646
+ # directory aged 2 000 s holding a file 1 s old read `tree quiet: 2001`, which
647
+ # is a false `idle` on an agent mid-edit. `-uall` lists `brandnew/f.txt`, so
648
+ # the file's own mtime is read and its parent is read beside it.
649
+ #
650
+ # THE FINGERPRINT KEEPS THE DEFAULT, and the asymmetry is correct: it asked
651
+ # *did these path NAMES change*, and a collapsed directory's name changes when
652
+ # the directory appears. This asks *when did anything move*, which the name
653
+ # cannot answer. Recorded in the plan's Open Points rather than widened
654
+ # silently.
655
+ status=$(git -C "$wt" status --porcelain -uall 2>/dev/null)
656
+
657
+ # THE SAME FILTER THE FINGERPRINT USED, for the same reason: this script's own
658
+ # records and the monitor's findings file are not work an agent left, and a
659
+ # raw status would make the monitor watch itself.
660
+ paths=''
661
+ if command -v plot_worker_dirty_filter >/dev/null 2>&1; then
662
+ paths=$(plot_worker_dirty_filter "$status")
663
+ else
664
+ paths=$(printf '%s' "$status" | cut -c4-)
665
+ fi
666
+
667
+ # Every path to stat, one per line, absolute. Built in awk rather than a bash
668
+ # loop so a desk holding hundreds of dirty paths costs one fork.
669
+ #
670
+ # A RENAME ARRIVES AS `old -> new` and the NEW name is the one that exists.
671
+ # A path with unusual bytes arrives quoted by git; the quotes are stripped so
672
+ # `stat` sees the name, which is lossy for a true embedded quote and the
673
+ # alternative is parsing C escapes in awk.
674
+ #
675
+ # A DELETED PATH DOES NOT EXIST, so its own mtime is unreadable. It is still
676
+ # listed — `stat` simply says nothing for it — and its parent below the root
677
+ # carries the change, which is exactly what a removal moves.
678
+ local list
679
+ list=$(printf '%s\n' "$paths" | awk -v wt="$wt" '
680
+ { line = $0 }
681
+ line == "" { next }
682
+ # A rename: take the destination, which is the name on disk now.
683
+ {
684
+ i = index(line, " -> ")
685
+ if (i > 0) line = substr(line, i + 4)
686
+ # Git quotes a path holding unusual bytes. Drop the quotes; the escapes
687
+ # inside are left as they are, and such a path simply reads unreadable.
688
+ if (substr(line, 1, 1) == "\"" && substr(line, length(line), 1) == "\"")
689
+ line = substr(line, 2, length(line) - 2)
690
+ if (line == "") next
691
+ print wt "/" line
692
+ # THE PARENT, BUT ONLY BELOW THE ROOT. A path with no `/` in it sits at
693
+ # the desk root, and the root is the directory the loop keeps touching.
694
+ if (index(line, "/") > 0) {
695
+ n = line
696
+ sub(/\/[^\/]*$/, "", n)
697
+ if (n != "" && n != ".") print wt "/" n
698
+ }
699
+ }
700
+ ')
701
+
702
+ # ONE `stat` CALL OVER EVERY PATH, and the flavour is probed once against a
703
+ # directory that certainly exists.
704
+ #
705
+ # THE ORDER IS LOAD-BEARING AND CI MEASURED WHY. On Linux `stat -f` is not an
706
+ # unknown flag — it means FILESYSTEM info and it SUCCEEDS, printing
707
+ # `Namelen: 255 Type: ext2/ext3`, which a caller then subtracts from a clock
708
+ # (`plot-fleetctl.sh`, and two tests that passed on macOS). So GNU's own form
709
+ # is asked first, because GNU is the implementation that mis-parses the other's
710
+ # flag, and each answer is validated as digits rather than trusted.
711
+ newest="$head_ct"
712
+ if [ -n "$list" ]; then
713
+ local fmt='' mtimes
714
+ if [ -n "$(stat -c %Y "$wt" 2>/dev/null)" ]; then fmt='gnu'
715
+ elif [ -n "$(stat -f %m "$wt" 2>/dev/null)" ]; then fmt='bsd'
716
+ fi
717
+ if [ -n "$fmt" ]; then
718
+ # A missing path makes `stat` exit non-zero while still printing the
719
+ # others, so the exit code is deliberately not read.
720
+ if [ "$fmt" = 'gnu' ]; then
721
+ mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -c %Y 2>/dev/null)
722
+ else
723
+ mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -f %m 2>/dev/null)
724
+ fi
725
+ # The maximum, taken in awk: only lines that are entirely digits count, so
726
+ # a filesystem report or an error line cannot become a timestamp.
727
+ local max
728
+ max=$(printf '%s\n' "$mtimes" | awk '/^[0-9]+$/ { if ($0 > m) m = $0 } END { if (m != "") print m }')
729
+ if [ -n "$max" ]; then
730
+ if [ -z "$newest" ] || [ "$max" -gt "$newest" ] 2>/dev/null; then newest="$max"; fi
731
+ fi
732
+ fi
733
+ fi
734
+
735
+ # NO COMMIT AND NO READABLE PATH is no reading at all — a desk whose git
736
+ # directory cannot be read, or a worktree with no history yet and nothing on
737
+ # the floor. It is not a very long silence.
738
+ [ -n "$newest" ] || { printf 'unreadable'; return 0; }
739
+
740
+ now=$(date +%s)
741
+ local quiet=$(( now - newest ))
742
+ # A time in the future — clock skew across a mounted volume — reads as zero
743
+ # rather than negative. A comparison against a window would behave correctly
744
+ # by accident here and not elsewhere; clamping says what is meant.
745
+ [ "$quiet" -lt 0 ] && quiet=0
746
+ printf '%s' "$quiet"
747
+ }
748
+
749
+ # Is this desk idle, from ONE reading of it?
750
+ #
751
+ # THE SHELL'S COPY OF `idleNow`, argument for argument. Six readings in, one
752
+ # word out:
753
+ #
754
+ # $1 pid alive | dead | unrecorded
755
+ # $2 spoken 1 spoken | 0 not (a reading, never an absence)
756
+ # $3 silence seconds since the newest transcript line, or any non-number
757
+ # $4 activity the sampler's word: `working` vetoes, `idle` and `` do not
758
+ # $5 treeQuiet seconds since the newest tree change, or any non-number
759
+ # $6 commits yes | no | unanswerable
760
+ # $7 window seconds a duration must reach
761
+ #
762
+ # ONE WORD ON STDOUT AND EXIT 0, ALWAYS. A caller that tests only for empty
763
+ # output cannot tell `silent` from *the function was missing*, so read the exit
764
+ # code: this prints `idle` or `silent` and returns 0, and a shell that never
765
+ # sourced this file returns 127 having printed nothing.
766
+ #
767
+ # `≥ window`, NOT `>`. The window is where the question becomes worth asking, so
768
+ # a desk exactly at it is eligible — the same boundary `quiet -lt window → busy`
769
+ # draws from the other side.
770
+ #
771
+ # THE CPU IS A VETO AND NOT THE VERDICT. Only `working` refuses, because only
772
+ # `working` says something is running. `idle` (a frozen subtree clock) and ``
773
+ # (no child holding a clock at all) agree here: past the window, each is an
774
+ # agent that has stopped. That is the opposite of how the old CPU-snapshot rule
775
+ # read the empty answer, and deliberately so — this line is reached only after
776
+ # the window has already elapsed.
777
+ #
778
+ # NO `gone` ARM. A dead pid answers `silent`: the wrapper that starts the agent
779
+ # knows the instant it ends and publishes `gone` itself.
780
+ #
781
+ # AN UNREADABLE VALUE ANSWERS `silent`, every one of them. An `unrecorded` pid,
782
+ # an `unavailable` transcript, an `unreadable` tree and an `unanswerable` commit
783
+ # question each withhold the finding, because a failure to observe is not
784
+ # evidence of something to see.
785
+ plot_worker_idle_now() { # $1..$7 as above → idle | silent
786
+ local pid="$1" spoken="$2" silence="$3" activity="$4" tree="$5" commits="$6" window="$7"
787
+
788
+ [ "$pid" = 'alive' ] || { printf 'silent'; return 0; }
789
+ [ "$spoken" = '1' ] || { printf 'silent'; return 0; }
790
+
791
+ # A non-numeric duration is a reading that was not taken. `unavailable`,
792
+ # `unreadable` and an empty string all land here, and so would a filesystem
793
+ # report that slipped past the validation above.
794
+ case "$window" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
795
+ case "$silence" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
796
+ [ "$silence" -ge "$window" ] || { printf 'silent'; return 0; }
797
+
798
+ [ "$activity" = 'working' ] && { printf 'silent'; return 0; }
799
+
800
+ case "$tree" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
801
+ [ "$tree" -ge "$window" ] || { printf 'silent'; return 0; }
802
+
803
+ [ "$commits" = 'yes' ] || { printf 'silent'; return 0; }
804
+ printf 'idle'
805
+ return 0
806
+ }
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # THE WATCHER'S OWN PASS — the readings `plot_worker_idle_now` is asked about
810
+ # ---------------------------------------------------------------------------
811
+ #
812
+ # MOVED FROM `plot-worker-monitor.sh` RATHER THAN REWRITTEN. The WorkerMonitor
813
+ # process is gone (`bug/the-loop-reports-idle`): the loop's own watcher
814
+ # subshell calls these instead, so the readings move to the file the loop
815
+ # already sources rather than living in a script that no longer runs.
816
+ #
817
+ # `monitor_has_commits` IS MOVED, NOT CHANGED. The scope guard that made it
818
+ # untouchable in wave 1 still holds — the `-- .` pathspec and the
819
+ # `origin/<default>` resolution are copied verbatim, including the comment
820
+ # that explains why a dispatched branch's own claim commit must not count.
821
+
822
+ # The reset epoch a desk is waiting out, or nothing.
823
+ #
824
+ # THE LOOP WRITES THE FILE AND THIS ONLY READS IT. `.plot-worker.limited`
825
+ # carries the reset epoch, the same instant as ISO text, and the limit line;
826
+ # only the first field is read here, because the caller compares integers and
827
+ # never parses a date.
828
+ #
829
+ # NOTHING IS ANSWERED FOR AN ABSENT, EMPTY OR UNPARSEABLE FILE, and the caller
830
+ # reads that as *this desk is not waiting*. A record whose first field is not a
831
+ # number is the same answer as no record: a reading that cannot be made must
832
+ # not widen into a reason to hold a verdict back.
833
+ plot_worker_limited_reset() { # $1=worktree → epoch seconds | ""
834
+ local file="$1/.plot-worker.limited" reset
835
+ [ -n "$1" ] && [ -r "$file" ] || return 0
836
+ reset=$(cut -f1 < "$file" 2>/dev/null | head -n1)
837
+ case "$reset" in (''|*[!0-9]*) return 0 ;; esac
838
+ printf '%s' "$reset"
839
+ }
840
+
841
+ # Are there commits on this branch yet? → 0 yes | 1 no | 2 unanswerable
842
+ #
843
+ # THE THIRD CONDITION ON `idle`, and the one that separates a stall from an
844
+ # agent still thinking about a hard first slice.
845
+ #
846
+ # COUNTED AGAINST THE LOCAL `origin/<default>` REF — never a fetch, because
847
+ # this reading makes no network call. And when there is no such ref the
848
+ # question is UNANSWERABLE, so this returns 2 and `idle` does not fire:
849
+ # counting against nothing would count the whole history from the root commit
850
+ # and read every branch in a remote-less repo as having committed.
851
+ plot_worker_has_commits() { # $1=worktree → 0 yes | 1 no | 2 unanswerable
852
+ [ -n "$1" ] && [ -d "$1" ] || return 2
853
+ local wt="$1" base n
854
+ base=$(git -C "$wt" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
855
+ [ -n "$base" ] || { git -C "$wt" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
856
+ [ -n "$base" ] || return 2
857
+ # COUNT THE AGENT'S WORK, NOT THE BRANCH'S COMMITS. `plot-dispatch.sh` writes
858
+ # `commit --allow-empty -m "plot: claim <branch>"` BEFORE the agent starts, so
859
+ # `$base..HEAD` is never zero on a dispatched branch and this condition could
860
+ # never refuse an `idle`. Measured 2026-08-30 (#538 red in CI): a worker
861
+ # burning CPU in `yes > /dev/null` was reported idle, because the one
862
+ # condition that could have saved it was satisfied by bookkeeping the agent
863
+ # did not do.
864
+ #
865
+ # The `-- .` pathspec is what does it: `rev-list` with a pathspec keeps only
866
+ # commits that TOUCHED A FILE, and the claim is empty by construction
867
+ # (`--allow-empty`). That is a property rather than a message match — a claim
868
+ # whose wording changes still reads as empty, and an agent committing an
869
+ # empty marker of its own is correctly not counted as work either.
870
+ n=$(git -C "$wt" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
871
+ case "$n" in ''|*[!0-9]*) return 2 ;; esac
872
+ [ "$n" -gt 0 ] && return 0
873
+ return 1
874
+ }
875
+
442
876
  # The total CPU time, in centiseconds, of a pid and every process descended from
443
877
  # it. Prints the number; prints `0` and returns non-zero when the pid names no
444
878
  # live process at all.