@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "plot-config.sh",
32
32
  "plot-default-branch.sh",
33
33
  "plot-deliver.sh",
34
+ "plot-desk-root.sh",
34
35
  "plot-dispatch.sh",
35
36
  "plot-fleet-scan.sh",
36
37
  "plot-host.sh",
@@ -38,11 +39,9 @@
38
39
  "plot-pr-merged.sh",
39
40
  "plot-reap.sh",
40
41
  "plot-release-refs.sh",
41
- "plot-resolve-artifact.sh",
42
42
  "plot-state-receipt.sh",
43
43
  "plot-tmp.sh",
44
44
  "plot-transcript-quiet.sh",
45
- "plot-worker-monitor.sh",
46
45
  "plot-worker-state.sh"
47
46
  ],
48
47
  "scripts": {
@@ -17,7 +17,66 @@
17
17
  # `plot_session_id`, which `plot-dispatch.sh` calls to launch an agent and
18
18
  # `plot-worker-loop.sh` calls when a hop moves the agent to a new branch; and
19
19
  # `manifest_resume_id` with `session_handle`, the conversation handle that the
20
- # loop passes to the prompt and `plot-worker-monitor.sh` probes for a transcript.
20
+ # loop passes to the prompt and the loop's own watcher probes for a transcript.
21
+
22
+ # ONE READER FOR EVERY STRING FIELD a manifest carries, and one for every
23
+ # counter. Six callers read one field each — `branch`, `session`, `worktree`,
24
+ # `resumeId`, `attempts`, `correctionAttempts` — and each carried its own
25
+ # fourteen-line copy of the same read-parse-default dance. The copies answered
26
+ # identically and drifted only in the field name, so the name is the argument.
27
+ #
28
+ # ABSENT, UNREADABLE AND WRONG-TYPED ARE ONE ANSWER, which is the contract
29
+ # every caller already documents: nothing for a string (status 1), `0` for a
30
+ # counter. A manifest that cannot be read is not permission to do anything, and
31
+ # a counter that cannot be read also cannot be raised.
32
+ manifest_string() { # $1=manifest $2=field → prints the value, or nothing
33
+ local manifest="$1" value
34
+ [ -n "$manifest" ] && [ -f "$manifest" ] || return 1
35
+ value=$(node -e '
36
+ const fs = require("fs");
37
+ try {
38
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
39
+ const v = manifest[process.argv[2]];
40
+ process.stdout.write(typeof v === "string" ? v : "");
41
+ } catch { process.stdout.write(""); }
42
+ ' "$manifest" "$2" 2>/dev/null) && [ -n "$value" ] || return 1
43
+ printf '%s' "$value"
44
+ }
45
+
46
+ # A non-negative integer field, or `0`. The node call itself only ever prints
47
+ # a clean digit string or "0", so a failed call is the one case needing a
48
+ # shell-side fallback.
49
+ manifest_count() { # $1=manifest $2=field → prints a count
50
+ local manifest="$1"
51
+ [ -n "$manifest" ] && [ -f "$manifest" ] || { printf '0'; return 0; }
52
+ node -e '
53
+ const fs = require("fs");
54
+ try {
55
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
56
+ const n = manifest[process.argv[2]];
57
+ process.stdout.write(Number.isInteger(n) && n >= 0 ? String(n) : "0");
58
+ } catch { process.stdout.write("0"); }
59
+ ' "$manifest" "$2" 2>/dev/null || printf '0'
60
+ }
61
+
62
+ # Raise a non-negative integer field by one, leaving every other field verbatim.
63
+ #
64
+ # THROUGH A TEMP FILE AND A RENAME, the shape every writer here takes: a reader
65
+ # never sees a partial manifest. A write that fails leaves the file alone and
66
+ # reports it; an absent manifest is not a failure.
67
+ raise_manifest_count() { # $1=manifest $2=field
68
+ local manifest="$1" tmp="$1.plot-count-tmp"
69
+ [ -n "$manifest" ] && [ -f "$manifest" ] || return 0
70
+ node -e '
71
+ const fs = require("fs");
72
+ const [file, field, tmp] = process.argv.slice(1);
73
+ const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
74
+ const n = manifest[field];
75
+ manifest[field] = (Number.isInteger(n) && n >= 0 ? n : 0) + 1;
76
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
77
+ ' "$manifest" "$2" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
78
+ mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
79
+ }
21
80
 
22
81
  # Clear `branch` when a slice finishes, so the window before the next one is
23
82
  # observable.
@@ -98,18 +157,7 @@ plot_session_id() {
98
157
  # `assigned_branch` in `plot-worker-loop.sh` already takes: no handle. A hand-started loop has no
99
158
  # manifest, and a manifest nobody can read is not a handle.
100
159
  manifest_resume_id() { # $1=manifest → prints the handle, or nothing
101
- local manifest="$1"
102
- [ -n "$manifest" ] && [ -f "$manifest" ] || return 1
103
- local id
104
- id=$(node -e '
105
- const fs = require("fs");
106
- try {
107
- const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
108
- process.stdout.write(typeof manifest.resumeId === "string" ? manifest.resumeId : "");
109
- } catch { process.stdout.write(""); }
110
- ' "$manifest" 2>/dev/null) || return 1
111
- [ -n "$id" ] || return 1
112
- printf '%s' "$id"
160
+ manifest_string "$1" resumeId
113
161
  }
114
162
 
115
163
  # THE HANDLE THE PROMPT CARRIES — the manifest's `resumeId`, or the launch id.
@@ -119,8 +167,8 @@ manifest_resume_id() { # $1=manifest → prints the handle, or nothing
119
167
  # the two answers are the same string and this reads as a no-op. It stops being
120
168
  # one the moment the handle diverges from the join key — which is what the two
121
169
  # fields exist to allow, and what a later `--fork-session` would do. The loop
122
- # and `plot-worker-monitor.sh` both call this, so the prompt and the idle
123
- # verdict ask about one conversation.
170
+ # calls this for the prompt and its own watcher calls it for the idle verdict,
171
+ # so both ask about one conversation.
124
172
  #
125
173
  # `$PLOT_SESSION_ID` IS THE FALLBACK, NOT THE SOURCE. A hand-started loop has no
126
174
  # manifest and a pre-`resumeId` manifest carries no handle; both are the launch
@@ -135,3 +183,51 @@ session_handle() { # → the handle, or nothing
135
183
  [ -n "${PLOT_SESSION_ID:-}" ] || return 1
136
184
  printf '%s' "$PLOT_SESSION_ID"
137
185
  }
186
+
187
+ # The live agents whose manifests name a branch, for `claimAnswer`'s
188
+ # `holders` — one session id per line, in the directory's own order.
189
+ #
190
+ # ASKS EACH MANIFEST'S OWN DESK, never the branch's checkout: an agent just
191
+ # handed the branch has not checked it out, so a check against the branch's
192
+ # worktree would answer nothing even while that agent's own desk is alive and
193
+ # genuinely holds the slice. `queue-reading.ts:302` takes the same `running` or
194
+ # `waiting` bar; a dead agent's manifest is not a holder (#1039's negative
195
+ # control).
196
+ #
197
+ # `$2` AND `$3` EXCLUDE DIFFERENT THINGS, and a caller supplies at most one.
198
+ # `plot-dispatch.sh --release` has no asker and excludes `release_wt` instead —
199
+ # the desk its OWN live-worker refusal already checks, so this must not
200
+ # duplicate that refusal's case with a different message for the same desk.
201
+ # `plot-worker-loop.sh`'s rejection excludes its OWN manifest file instead —
202
+ # the supervisor wrote the rejected branch into it before the push, so
203
+ # counting it would answer `held-by-agent` on every rejection.
204
+ live_holders_of_branch() { # $1=registry_dir $2=branch $3=exclude_manifest $4=exclude_worktree → "session\tworktree" lines
205
+ local dir="$1" branch="$2" exclude_manifest="${3:-}" exclude_worktree="${4:-}" m m_wt
206
+ [ -d "$dir" ] || return 0
207
+ for m in "$dir"/*.json; do
208
+ [ -f "$m" ] && [ "$m" != "$exclude_manifest" ] || continue
209
+ [ "$(manifest_string "$m" branch)" = "$branch" ] || continue
210
+ m_wt=$(manifest_string "$m" worktree) || continue
211
+ [ -d "$m_wt" ] && [ "$m_wt" != "$exclude_worktree" ] || continue
212
+ case "$(plot_worker_state "$m_wt" "" | cut -f1)" in
213
+ running|waiting) printf '%s\t%s\n' "$(manifest_string "$m" session)" "$m_wt" ;;
214
+ esac
215
+ done
216
+ }
217
+
218
+ # WHAT A CLAIM MEANS, ASKED OF THE DOMAIN. One of `claimAnswer`'s five words,
219
+ # or nothing when the bundle is absent — a checkout that vendored the skills
220
+ # without building the board answers no question rather than a wrong one.
221
+ #
222
+ # ONE ASKER FOR BOTH CALLERS. `plot-dispatch.sh --release` asks with `ref:
223
+ # unknown` and no log, because `held-by-agent` follows from `holders` alone;
224
+ # `plot-worker-loop.sh`'s rejection path supplies all three. The JSON shape is
225
+ # the bundle's contract, and two hand-built copies of it would drift.
226
+ claim_answer() { # $1=bundle $2=ref $3=log $4=holders, newline-separated → the word, or nothing
227
+ [ -f "$1" ] || return 1
228
+ printf '%s\n' "$4" | node -e '
229
+ const holders = require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean);
230
+ const [ref, log] = process.argv.slice(1);
231
+ process.stdout.write(JSON.stringify({ ref, log: ref === "present" ? log : null, holders }));
232
+ ' "$2" "$3" | node "$1" 2>/dev/null | cut -f1
233
+ }
@@ -93,9 +93,13 @@ Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
93
93
  the environment, exactly as the wrapper's other children do:
94
94
 
95
95
  PLOT_BRANCH the branch whose debts this monitor will report
96
- PLOT_WORKTREE the desk it reads
96
+ PLOT_WORKTREE the desk it STARTS on; it follows the manifest's
97
+ `worktree` from then on (PLOT_MANIFEST_FILE below)
98
+ PLOT_MANIFEST_FILE the manifest this agent is named in, re-read each pass;
99
+ absent or unreadable keeps watching PLOT_WORKTREE
97
100
  PLOT_MONITOR_FILE where findings are published (default:
98
- $PLOT_WORKTREE/.plot-worker.monitor.agent.jsonl)
101
+ $PLOT_WORKTREE/.plot-worker.monitor.agent.jsonl). Set
102
+ explicitly, it WINS and does not follow a hop.
99
103
  PLOT_MONITOR_INTERVAL seconds between passes (default 300)
100
104
 
101
105
  --once take one sample and exit, rather than looping. A test
@@ -116,7 +120,14 @@ done
116
120
  monitor='AgentMonitor'
117
121
 
118
122
  branch="${PLOT_BRANCH:-}"
119
- worktree="${PLOT_WORKTREE:-}"
123
+ # THE DESK THIS MONITOR WAS LAUNCHED ON, FIXED FOR ITS WHOLE LIFE. `worktree`
124
+ # itself is no longer fixed: `monitor_pass` reassigns it every pass by asking
125
+ # `plot_watched_desk`, which follows a hop to the manifest's new `worktree`
126
+ # field. This is the launch desk `plot_watched_desk` falls back to when the
127
+ # manifest carries no override — never read directly after startup.
128
+ launched_worktree="${PLOT_WORKTREE:-}"
129
+ worktree="$launched_worktree"
130
+ manifest_file="${PLOT_MANIFEST_FILE:-}"
120
131
  # FIVE MINUTES IS A HOST BUDGET, NOT CAUTION. Its findings need a PR lookup, and
121
132
  # this repository has already measured what happens when host questions ride a
122
133
  # fast loop. Against a stall that lasted 50 minutes, five makes it visible 45
@@ -126,7 +137,11 @@ worktree="${PLOT_WORKTREE:-}"
126
137
  # prevent.
127
138
  interval="${PLOT_MONITOR_INTERVAL:-300}"
128
139
 
129
- findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
140
+ # IF `PLOT_MONITOR_FILE` IS SET IT WINS AND DOES NOT FOLLOW THE DESK — the
141
+ # existing contract this usage text already states. Otherwise `findings`
142
+ # follows `worktree`, reassigned alongside it at the top of `monitor_pass`.
143
+ monitor_file_override="${PLOT_MONITOR_FILE:-}"
144
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
130
145
 
131
146
  # THE SUBJECT, read the same way the WorkerMonitor reads it: `.plot-worker.pid`
132
147
  # names the AGENT, and the wrapper passes its path in `PLOT_PID_FILE`.
@@ -478,7 +493,30 @@ sample_finding() { # → prints "finding\tevidence", or nothing
478
493
 
479
494
  # One full pass: sample, publish only on a change.
480
495
  monitor_pass() {
481
- local row finding evidence
496
+ local row finding evidence new_worktree
497
+ # REASSIGNED ONCE, HERE, RATHER THAN THREADED THROUGH EACH READER.
498
+ # `worktree` feeds `plot_worker_dirty`, `plot_worker_blocked` and this
499
+ # monitor's transcript probe, so a hop is picked up in one place rather than
500
+ # three. `PLOT_MONITOR_FILE`, when set, WINS and does not follow the desk —
501
+ # the existing contract the usage text states.
502
+ new_worktree=$(plot_watched_desk "$manifest_file" "$launched_worktree")
503
+
504
+ # A DESK CHANGE RESETS `published`/`since` DELIBERATELY. The debt they
505
+ # remember is the OLD desk's: a finding like `holds unlanded work` is about
506
+ # commits and files on that specific tree, and after a hop the new desk has
507
+ # never been measured. Carrying the old `published` forward would compare
508
+ # the new desk's first reading against the old one's last and could publish
509
+ # `clear` for a debt that was never the new desk's to begin with — the exact
510
+ # trap the brief names. Resetting to blank treats the new desk as a fresh
511
+ # subject: a real finding there publishes as new, and a clean desk publishes
512
+ # nothing at all, which is silence rather than a false `clear`.
513
+ if [ "$new_worktree" != "$worktree" ]; then
514
+ published=''
515
+ since=''
516
+ fi
517
+ worktree="$new_worktree"
518
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
519
+
482
520
  row=$(sample_finding)
483
521
  finding="${row%% *}"
484
522
  evidence=''