@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/dist/board-server.mjs +166 -158
- package/package.json +3 -1
- package/plot-agent-manifest.sh +137 -0
- package/plot-approve.sh +25 -1
- package/plot-budget.sh +378 -19
- package/plot-config.sh +86 -2
- package/plot-deliver.sh +26 -3
- package/plot-dispatch.sh +307 -43
- package/plot-fleet-scan.sh +438 -229
- package/plot-host.sh +322 -84
- package/plot-plan-meta.sh +68 -13
- package/plot-pr-merged.sh +29 -2
- package/plot-reap.sh +212 -23
- package/plot-resolve-artifact.sh +4 -2
- package/plot-tmp.sh +108 -0
- package/plot-transcript-quiet.sh +29 -1
- package/plot-worker-monitor.sh +66 -5
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
|
package/plot-transcript-quiet.sh
CHANGED
|
@@ -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.
|
package/plot-worker-monitor.sh
CHANGED
|
@@ -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 —
|
|
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 `
|
|
560
|
-
# design: silence means healthy, and the AgentMonitor's slower
|
|
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
|
|