@plot-pm/board 0.14.4 → 0.16.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.
package/plot-tmp.sh ADDED
@@ -0,0 +1,108 @@
1
+ # plot-tmp.sh — every temp path a Plot script creates, and the process's only
2
+ # EXIT/INT/TERM traps. SOURCED, not run.
3
+ #
4
+ # . "$SCRIPT_DIR/plot-tmp.sh"
5
+ # plot_tmpdir work fleet-ref # $work = $TMPDIR/plot-fleet-ref.XXXXXX (a directory)
6
+ # plot_tmpfile err host-err # $err = $TMPDIR/plot-host-err.XXXXXX (a file)
7
+ # plot_on_exit budget_memo_clear # a command the exit handler runs
8
+ #
9
+ # THE TEMPLATE IS THE REASON THIS FILE EXISTS. On macOS, `mktemp` with no
10
+ # template and `mktemp -t` both write to `_CS_DARWIN_USER_TEMP_DIR` and ignore
11
+ # `TMPDIR` (`man mktemp`). Only an explicit `"${TMPDIR:-/tmp}/plot-<prefix>.XXXXXX"`
12
+ # lands under `TMPDIR`, and BSD `mktemp` requires the X's to trail. Every path
13
+ # carries the `plot-` prefix, which is the name `plot-reap.sh --sweep-temp`
14
+ # removes after a SIGKILL skipped the traps.
15
+ #
16
+ # ASSIGNMENT BY NAME, NEVER `d=$(plot_tmpdir x)`. The functions assign through
17
+ # `printf -v` and print nothing. `scripts/check-temp-paths.sh` refuses the
18
+ # substitution form.
19
+ #
20
+ # THE REGISTRY IS A FILE KEYED BY `$$`, NOT A SHELL ARRAY. `$$` is the owning
21
+ # script's pid in every subshell, so a registration made inside `$(…)` or
22
+ # `( … ) &` reaches the owner's trap; an array would be a subshell's copy and
23
+ # the path would leak. Measured 2026-09-30: `plot-host.sh` creates two spool
24
+ # files inside a command substitution.
25
+ #
26
+ # THE TRAPS ARE INSTALLED HERE, ONCE, AT SOURCE TIME. A trap installed on the
27
+ # first call would be installed inside that call's substitution and remove the
28
+ # path when the substitution closed. `PLOT_TMP_LOADED` makes a second source in
29
+ # the same process a no-op, because a re-run setup would truncate the live
30
+ # registry. INT and TERM run the cleanup, clear their own trap and re-raise, so
31
+ # the script stops with 130 or 143 rather than running on to exit 0.
32
+ #
33
+ # A script that sources this must not install its own EXIT, INT or TERM trap:
34
+ # the last `trap` wins, and that replacement is the defect this file fixes
35
+ # (`plot-fleet-scan.sh` left one ~955-file cache per run). Use `plot_on_exit`.
36
+
37
+ if [ "${PLOT_TMP_LOADED:-}" = "$$" ]; then
38
+ return 0 2>/dev/null || exit 0
39
+ fi
40
+ PLOT_TMP_LOADED=$$
41
+
42
+ # Fixed at first source: a later `TMPDIR` change in the script does not move it.
43
+ # A file already at this path belongs to a dead process that had the same pid,
44
+ # and its `c:` commands are not this process's, so it is replaced, never read.
45
+ PLOT_TMP_REGISTRY="${TMPDIR:-/tmp}/plot-reg.$$"
46
+ rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
47
+ : > "$PLOT_TMP_REGISTRY" 2>/dev/null || true
48
+
49
+ # One line per entry, in registration order: `p:<path>` or `c:<command>`.
50
+ _plot_tmp_register() {
51
+ printf '%s:%s\n' "$1" "$2" >> "$PLOT_TMP_REGISTRY" 2>/dev/null || true
52
+ }
53
+
54
+ # plot_tmpdir VAR prefix — create a directory, register it, assign it to VAR.
55
+ plot_tmpdir() {
56
+ local __plot_tmp_new
57
+ __plot_tmp_new=$(mktemp -d "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
58
+ _plot_tmp_register p "$__plot_tmp_new"
59
+ printf -v "$1" '%s' "$__plot_tmp_new"
60
+ }
61
+
62
+ # plot_tmpfile VAR prefix — create a file, register it, assign it to VAR.
63
+ plot_tmpfile() {
64
+ local __plot_tmp_new
65
+ __plot_tmp_new=$(mktemp "${TMPDIR:-/tmp}/plot-$2.XXXXXX") || return 1
66
+ _plot_tmp_register p "$__plot_tmp_new"
67
+ printf -v "$1" '%s' "$__plot_tmp_new"
68
+ }
69
+
70
+ # plot_on_exit command — run `command` (one line) when the process ends.
71
+ plot_on_exit() {
72
+ _plot_tmp_register c "$*"
73
+ }
74
+
75
+ # Runs every registered command and removes every registered path, in
76
+ # registration order, then removes the registry. Runs once: the registry is
77
+ # gone afterwards, so a second call finds nothing.
78
+ _plot_tmp_cleanup() {
79
+ local __plot_tmp_line
80
+ [ -f "$PLOT_TMP_REGISTRY" ] || return 0
81
+ while IFS= read -r __plot_tmp_line; do
82
+ case $__plot_tmp_line in
83
+ c:*) eval "${__plot_tmp_line#c:}" ;;
84
+ p:*) rm -rf -- "${__plot_tmp_line#p:}" 2>/dev/null ;;
85
+ esac
86
+ done < "$PLOT_TMP_REGISTRY"
87
+ rm -f -- "$PLOT_TMP_REGISTRY" 2>/dev/null
88
+ return 0
89
+ }
90
+
91
+ _plot_tmp_on_exit() {
92
+ local __plot_tmp_rc=$?
93
+ _plot_tmp_cleanup
94
+ exit "$__plot_tmp_rc"
95
+ }
96
+
97
+ # $1 is the signal name, $2 its number. `kill` re-raises it with the default
98
+ # disposition; the `exit` is reached only if the shell defers the delivery.
99
+ _plot_tmp_on_signal() {
100
+ trap - EXIT "$1"
101
+ _plot_tmp_cleanup
102
+ kill -"$1" "$$"
103
+ exit $((128 + $2))
104
+ }
105
+
106
+ trap _plot_tmp_on_exit EXIT
107
+ trap '_plot_tmp_on_signal INT 2' INT
108
+ trap '_plot_tmp_on_signal TERM 15' TERM
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # The ONE answer to "how long has this worktree's agent been quiet?" — sourced,
3
- # not run, by `plot-worker-monitor.sh`.
3
+ # not run, by `plot-worker-monitor.sh` and `plot-worker-loop.sh`.
4
4
  #
5
5
  # It reads the AGENT rather than the machine. A `claude -p` session appends a
6
6
  # timestamped line to its transcript for every model turn, tool call and tool
@@ -40,6 +40,14 @@
40
40
  # belongs to no one. `rules/spend.ts` states that side; the two read the same
41
41
  # files and must not be made to share a join.
42
42
  #
43
+ # A THIRD QUESTION USES BOTH KEYS: *has this worker's conversation written?* It
44
+ # is per worktree AND per conversation handle, and it is asked as a file's
45
+ # existence, never as a time: `plot_transcript_exists` below. The loop asks it
46
+ # to choose `--session-id` or `--resume`; the worker monitor asks it before it
47
+ # calls a quiet desk idle, because until the new conversation writes its first
48
+ # line the desk's newest file belongs to the previous one. It does not change
49
+ # the quiet number, which stays about the desk.
50
+ #
43
51
  # So the join here is the one `plot-quiet-stretch.mjs` already made and proved
44
52
  # on 23 real sessions: the runtime stores a session under
45
53
  # `$HOME/.claude/projects/<slug>` where the slug is the WORKTREE PATH with `/`
@@ -121,6 +129,26 @@ plot_transcript_quiet_seconds() { # $1=worktree → seconds | unavailable
121
129
  printf '%s' "$quiet"
122
130
  }
123
131
 
132
+ # Does the conversation `id` have a transcript at this worktree?
133
+ #
134
+ # EXISTENCE, NOT A TIMESTAMP. The runtime creates `<id>.jsonl` with its first
135
+ # line and appends to it after, under both `--session-id` and `--resume`. So the
136
+ # file's presence says the conversation has written, and no comparison of
137
+ # clocks is made, so a file created in the same second as a manifest write
138
+ # reads as present.
139
+ #
140
+ # NO HANDLE AND NO FILE ARE ONE ANSWER HERE, and a caller that must tell them
141
+ # apart checks the handle first. `session_flag` reads both as *create*; the
142
+ # monitor's port does not, because a monitor with no handle must not read every
143
+ # quiet worker as unspoken.
144
+ plot_transcript_exists() { # $1=worktree $2=id → 0 found | 1 not
145
+ local wt="$1" id="$2" dir
146
+ [ -n "$wt" ] && [ -n "$id" ] || return 1
147
+ dir=$(plot_transcript_dir "$wt" 2>/dev/null) || return 1
148
+ [ -n "$dir" ] || return 1
149
+ [ -f "$dir/$id.jsonl" ]
150
+ }
151
+
124
152
  # A file's modification time as a unix epoch. BSD and GNU `stat` disagree on the
125
153
  # flag, and a monitor that works on the author's laptop and not in CI is a
126
154
  # monitor nobody trusts.
@@ -185,6 +185,11 @@ the environment, exactly as the wrapper's other children do:
185
185
  PLOT_MONITOR_FILE where findings are published (default:
186
186
  $PLOT_WORKTREE/.plot-worker.monitor.worker.jsonl)
187
187
  PLOT_MONITOR_INTERVAL seconds between passes (default 30)
188
+ PLOT_SESSION_ID the launch session id; the handle when the manifest
189
+ carries no `resumeId`
190
+ PLOT_MANIFEST_FILE the agent's manifest, whose `resumeId` names the current
191
+ conversation. With neither set, `idle` is judged on the
192
+ desk alone and one line on stderr says so.
188
193
 
189
194
  --once take one sample and exit, rather than looping. A single pass can
190
195
  never publish `idle` — that needs two — so this is how a test drives
@@ -255,6 +260,15 @@ plot_transcript_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-transcri
255
260
  # shellcheck source=plot-transcript-quiet.sh
256
261
  if [ -r "$plot_transcript_lib" ]; then . "$plot_transcript_lib"; fi
257
262
 
263
+ # THE CONVERSATION HANDLE — `session_handle`, the one the loop hands the prompt.
264
+ # The manifest's `resumeId`, else `PLOT_SESSION_ID`, both read from the
265
+ # environment the wrapper passes down. Never `plot_manifest_for_worktree`: it
266
+ # resolves `--show-toplevel` to the desk and ignores `Agent registry`, so it
267
+ # names a directory that does not exist (#1086).
268
+ plot_manifest_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-agent-manifest.sh"
269
+ # shellcheck source=plot-agent-manifest.sh
270
+ if [ -r "$plot_manifest_lib" ]; then . "$plot_manifest_lib"; fi
271
+
258
272
  # HOW LONG A TRANSCRIPT MUST BE QUIET BEFORE THE QUESTION IS EVEN ASKED.
259
273
  #
260
274
  # 900 s, and the number comes from wave 1's measurement rather than from taste.
@@ -318,7 +332,7 @@ publish() { # $1=finding $2=evidence $3=since
318
332
  }
319
333
 
320
334
  # ---------------------------------------------------------------------------
321
- # THE PORTS — four named seams, so every branch is reachable from a test
335
+ # THE PORTS — seven named seams, so every branch is reachable from a test
322
336
  # ---------------------------------------------------------------------------
323
337
  #
324
338
  # Each of these is one question against the machine, and each is a `monitor_*`
@@ -374,6 +388,29 @@ monitor_transcript_quiet() { # → seconds | unavailable
374
388
  plot_transcript_quiet_seconds "$worktree"
375
389
  }
376
390
 
391
+ # Has THIS worker's conversation written yet?
392
+ #
393
+ # THE DESK-WIDE NUMBER CANNOT SAY. After a hop to a new branch the loop mints a
394
+ # fresh handle, and the new conversation has no transcript file until its first
395
+ # line. Until then the desk's newest file is the PREVIOUS slice's, and its
396
+ # silence is not this worker's. So the monitor asks the loop's own probe with
397
+ # the loop's own handle: one probe, two readers, one answer.
398
+ #
399
+ # THREE ANSWERS, AND THE THIRD IS NOT THE SECOND. `0` the handle's file exists,
400
+ # `1` it does not, `2` there is no handle to ask about. `plot_transcript_exists`
401
+ # reads *no handle* as *no file*, which suits `session_flag`; here it would
402
+ # make a hand-started monitor read every quiet worker as unspoken and disable
403
+ # `idle` silently. So the handle is checked here, before the probe.
404
+ monitor_conversation_spoken() { # → 0 spoken | 1 unspoken | 2 no handle
405
+ command -v session_handle >/dev/null 2>&1 || return 2
406
+ command -v plot_transcript_exists >/dev/null 2>&1 || return 2
407
+ local handle
408
+ handle=$(session_handle) || return 2
409
+ [ -n "$handle" ] || return 2
410
+ plot_transcript_exists "$worktree" "$handle" && return 0
411
+ return 1
412
+ }
413
+
377
414
  # A cheap stand-in for "the tree as it is right now", compared between passes.
378
415
  #
379
416
  # IT GOES THROUGH `plot_worker_dirty_filter`, which is not an optimisation — it
@@ -462,7 +499,7 @@ since=''
462
499
  # every other question meaningless — you cannot measure the CPU of a subtree
463
500
  # that is not there, and `plot_worker_activity` would answer "" for it anyway,
464
501
  # which is indistinguishable from a live pid with no children.
465
- sample_verdict() { # → gone | quiet | busy | unknown
502
+ sample_verdict() { # → gone | quiet | busy | unknown | unspoken
466
503
  local alive
467
504
  monitor_pid_alive; alive=$?
468
505
  [ "$alive" = 1 ] && { printf 'gone'; return; }
@@ -500,6 +537,18 @@ sample_verdict() { # → gone | quiet | busy | unknown
500
537
  # needs asking: no CPU sample can overturn a line written seconds ago.
501
538
  if [ "$quiet" -lt "$PLOT_MONITOR_QUIET_SECONDS" ]; then printf 'busy'; return; fi
502
539
 
540
+ # PAST THE WINDOW, AND ONLY HERE, ASK WHETHER THIS CONVERSATION HAS WRITTEN.
541
+ # The number is the desk's; a new conversation with no file yet has produced
542
+ # none of its silence. `unspoken` is a reading that was made, and it is not
543
+ # `unknown`, which is no reading. Only `1` answers it: with no handle (`2`)
544
+ # the verdict is judged on the desk alone, as before this port existed.
545
+ #
546
+ # LAZY ON PURPOSE. `session_handle` starts one `node` (about 35 ms), so a
547
+ # worker inside the window never pays it.
548
+ local spoken
549
+ monitor_conversation_spoken; spoken=$?
550
+ [ "$spoken" = 1 ] && { printf 'unspoken'; return; }
551
+
503
552
  # PAST THE WINDOW, THE SECOND READING DECIDES — and it answers a question the
504
553
  # transcript cannot. A transcript is equally quiet whether the agent is
505
554
  # waiting on a model or waiting on its own 20-minute test suite. 28 of the 37
@@ -556,9 +605,13 @@ monitor_pass() {
556
605
  # failure to observe is not evidence of something to see.
557
606
  fi
558
607
  ;;
559
- # `busy` and `unknown` are not findings. Nothing is published, which is the
560
- # design: silence means healthy, and the AgentMonitor's slower loop is what
561
- # catches a worker that finished without saying so.
608
+ # `busy`, `unknown` and `unspoken` are not findings. Nothing is published,
609
+ # which is the design: silence means healthy, and the AgentMonitor's slower
610
+ # loop is what catches a worker that finished without saying so. `unspoken`
611
+ # is recorded as `prev_verdict`, so `idle` needs two `quiet` passes after
612
+ # the conversation's first line. No grace period bounds it: a prompt that
613
+ # stays alive and never writes a line ends at `Worker bound`, the cost
614
+ # `unknown` already carries.
562
615
  esac
563
616
 
564
617
  prev_verdict="$verdict"
@@ -589,6 +642,14 @@ monitor_pass() {
589
642
  # line defines, and nothing below it runs when the guard is set.
590
643
  [ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
591
644
 
645
+ # ONE LINE AT START WHEN THERE IS NO HANDLE. Every wrapper-started monitor has
646
+ # one, because `plot-dispatch.sh` sets `PLOT_SESSION_ID` on every launch; only a
647
+ # monitor started by hand has none. It then behaves as it did before the
648
+ # conversation probe, and says so rather than degrading silently.
649
+ if [ -z "${PLOT_SESSION_ID:-}" ] && [ -z "${PLOT_MANIFEST_FILE:-}" ]; then
650
+ echo 'plot-worker-monitor: no session handle (PLOT_SESSION_ID and PLOT_MANIFEST_FILE unset) — idle is judged on the desk alone' >&2
651
+ fi
652
+
592
653
  monitor_pass
593
654
  [ "$once" = 1 ] && exit 0
594
655