@plot-pm/board 0.14.4 → 0.15.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 +164 -158
- package/package.json +2 -1
- package/plot-agent-manifest.sh +58 -0
- package/plot-approve.sh +23 -1
- package/plot-config.sh +34 -1
- package/plot-deliver.sh +24 -3
- package/plot-dispatch.sh +283 -13
- package/plot-fleet-scan.sh +387 -79
- package/plot-host.sh +95 -12
- package/plot-plan-meta.sh +10 -3
- package/plot-pr-merged.sh +29 -2
- package/plot-reap.sh +115 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist/board-server.mjs",
|
|
25
25
|
"plot-agent-monitor.sh",
|
|
26
|
+
"plot-agent-manifest.sh",
|
|
26
27
|
"plot-build-monitor.sh",
|
|
27
28
|
"plot-monitor-subject.sh",
|
|
28
29
|
"plot-approve.sh",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the ONE writer that clears an agent manifest's `branch` field.
|
|
3
|
+
#
|
|
4
|
+
# SOURCED, NOT RUN — the shape `plot-worker-state.sh` and `plot-pr-merged.sh`
|
|
5
|
+
# already take. Two scripts clear an assignment and must clear it one way:
|
|
6
|
+
#
|
|
7
|
+
# - `plot-worker-loop.sh` clears its own manifest when a slice finishes, so
|
|
8
|
+
# the window before the next hand-over is observable as `branch: ""`;
|
|
9
|
+
# - `plot-dispatch.sh --release <branch>` clears every manifest naming an
|
|
10
|
+
# abandoned slice, beside deleting the claim ref.
|
|
11
|
+
#
|
|
12
|
+
# The function lived inside `plot-worker-loop.sh` until the second caller
|
|
13
|
+
# arrived. The loop is a script, not a library, so sourcing it would run it; the
|
|
14
|
+
# body moved here unchanged and the loop sources this file instead.
|
|
15
|
+
#
|
|
16
|
+
# Defines one function and does nothing else on load.
|
|
17
|
+
|
|
18
|
+
# Clear `branch` when a slice finishes, so the window before the next one is
|
|
19
|
+
# observable.
|
|
20
|
+
#
|
|
21
|
+
# WHY THIS EXISTS. `free = process alive AND manifest names no branch`, and the
|
|
22
|
+
# second half was unreachable. `seal_declaration` runs the moment a branch is
|
|
23
|
+
# done; `update_manifest_on_hop` runs after `--next` answers and a worktree is
|
|
24
|
+
# built. Between those two points the agent genuinely holds no slice and the
|
|
25
|
+
# manifest still named the last one, so `isFree`'s empty-branch arm — written,
|
|
26
|
+
# exported and unit-tested since `a-dispatch-asks-for-a-free-agent` — had no
|
|
27
|
+
# production caller that could ever satisfy it. Measured 2026-09-02: 2
|
|
28
|
+
# manifests on this estate, neither ever carrying `branch: ""`.
|
|
29
|
+
#
|
|
30
|
+
# `branch` AND ONLY `branch`. `worktree` still names the desk the agent is
|
|
31
|
+
# sitting at — it has not moved, and clearing it would take the transcript join
|
|
32
|
+
# and the liveness check with it, since both are keyed on the worktree path.
|
|
33
|
+
# `wavesCount` counts hops and no hop has happened yet. The node one-liner
|
|
34
|
+
# round-trips the whole object, so every other field survives verbatim, the same
|
|
35
|
+
# property `update_manifest_on_hop` records.
|
|
36
|
+
#
|
|
37
|
+
# ADDED, NOT SUBSTITUTED. The hop still rewrites `branch` and `worktree`
|
|
38
|
+
# together; this writes the empty value that sits between two slices. A worker
|
|
39
|
+
# that finishes its last branch exits with the manifest cleared and the exit
|
|
40
|
+
# trap removes the file, so the empty value is never a leftover.
|
|
41
|
+
#
|
|
42
|
+
# ABSENT IS NOT A FAILURE. No manifest — a hand-started loop, an older
|
|
43
|
+
# dispatcher — means there is nothing to clear and nothing to report, so this
|
|
44
|
+
# returns 0 like `update_manifest_on_hop` does.
|
|
45
|
+
clear_manifest_branch() { # $1=manifest
|
|
46
|
+
local manifest="$1"
|
|
47
|
+
[ -f "$manifest" ] || return 0
|
|
48
|
+
|
|
49
|
+
local tmp="$manifest.plot-free-tmp"
|
|
50
|
+
node -e '
|
|
51
|
+
const fs = require("fs");
|
|
52
|
+
const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
53
|
+
manifest.branch = "";
|
|
54
|
+
fs.writeFileSync(process.argv[2], JSON.stringify(manifest, null, 2) + "\n");
|
|
55
|
+
' "$manifest" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
56
|
+
|
|
57
|
+
mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
|
|
58
|
+
}
|
package/plot-approve.sh
CHANGED
|
@@ -227,7 +227,25 @@ else
|
|
|
227
227
|
pr_branch="idea/$slug"
|
|
228
228
|
fi
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
# THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
|
|
231
|
+
# with `state: NONE` when the host answered that the branch has no PR, and
|
|
232
|
+
# non-zero when the host could not be asked: 3 for a refused or failed call
|
|
233
|
+
# (a rate limit included), 4 for a backend with no answer at all. Only the
|
|
234
|
+
# first is an absence. The other two stop here with the host's own words and
|
|
235
|
+
# name no repair to the branch, because nothing about the branch was read.
|
|
236
|
+
pr_err_file=$(mktemp "${TMPDIR:-/tmp}/plot-approve-pr.XXXXXX")
|
|
237
|
+
pr_rc=0
|
|
238
|
+
pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
|
|
239
|
+
pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
|
|
240
|
+
if [ "$pr_rc" = 4 ]; then
|
|
241
|
+
die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
|
|
242
|
+
${pr_err:-The host adapter gave no reason.}
|
|
243
|
+
This backend cannot report a PR's state, so the approval cannot read its gate. The plan was not approved and its phase is unchanged."
|
|
244
|
+
elif [ "$pr_rc" != 0 ]; then
|
|
245
|
+
die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
|
|
246
|
+
${pr_err:-The host adapter gave no reason.}
|
|
247
|
+
The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
|
|
248
|
+
fi
|
|
231
249
|
[ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
|
|
232
250
|
pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
|
|
233
251
|
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
|
|
@@ -420,6 +438,10 @@ append_approved_line() { # $1=in $2=out $3=record
|
|
|
420
438
|
insert = start
|
|
421
439
|
for (i = start + 1; i <= n; i++) {
|
|
422
440
|
if (lines[i] ~ /^##[ \t]/) break
|
|
441
|
+
# An HTML comment ends the writable region. Checked BEFORE the
|
|
442
|
+
# placeholder arms so a commented-out `- **Approved:**` template line
|
|
443
|
+
# is never mistaken for the slot to fill.
|
|
444
|
+
if (lines[i] ~ /<!--/) break
|
|
423
445
|
if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Approved:\*\*[ \t]*$/) { slot = i; break }
|
|
424
446
|
if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
|
|
425
447
|
}
|
package/plot-config.sh
CHANGED
|
@@ -80,6 +80,13 @@
|
|
|
80
80
|
# here can invoke a skill. Absent (or `none`) = the button
|
|
81
81
|
# refuses and names this key as the fix, rather than
|
|
82
82
|
# accepting the click and doing nothing.
|
|
83
|
+
# Interrogate command how the board runs `/plot-panel <plan path>` on a Draft
|
|
84
|
+
# plan (the card's `Interrogate` button); the prompt is
|
|
85
|
+
# appended as one argument and names a FILE the board
|
|
86
|
+
# wrote. REQUIRED for the same reason as `Idea command`:
|
|
87
|
+
# a panel fans out N agents reading a plan, and no script
|
|
88
|
+
# can do that. Absent (or `none`) = the button renders
|
|
89
|
+
# disabled and names this key as the fix.
|
|
83
90
|
# Brief command how /plot-dispatch runs an agent headless to WRITE a
|
|
84
91
|
# missing hand-off brief. The prompt is appended as one
|
|
85
92
|
# argument and asks for `/plot-implement <slug>`, whose
|
|
@@ -95,6 +102,16 @@
|
|
|
95
102
|
# Hosts plans yes | no (no = refuse plan files)
|
|
96
103
|
# Tracker plot | jira | github-issues | linear (+ URL)
|
|
97
104
|
# (plot = plans in this repo ARE the tracker; absent = same)
|
|
105
|
+
# Tracker delivered status
|
|
106
|
+
# the status word a plan's issues are set to when the plan
|
|
107
|
+
# reaches Delivered, in the tracker's own vocabulary
|
|
108
|
+
# (`In Review`, `Done`). Read by plot-issue-status.sh.
|
|
109
|
+
# Absent or empty = Delivered writes nothing — never an
|
|
110
|
+
# empty status, and never the released word instead.
|
|
111
|
+
# Tracker released status
|
|
112
|
+
# the same for Released. A team whose *Done* means
|
|
113
|
+
# *shipped* sets only this one; a team watching progress
|
|
114
|
+
# sets both.
|
|
98
115
|
# Ticket prefixes the tracker project keys this repository's work lives
|
|
99
116
|
# in, comma-separated (`PROJ-A, PROJ-B`). NOT
|
|
100
117
|
# `Branch prefixes`, which sits next to it and holds
|
|
@@ -142,7 +159,23 @@ if [ "$cmd" != "get" ]; then
|
|
|
142
159
|
exit 1
|
|
143
160
|
fi
|
|
144
161
|
|
|
145
|
-
|
|
162
|
+
# THE CALLER'S ROOT IS TAKEN WHERE IT OFFERS ONE. `git rev-parse` is ~5 ms and
|
|
163
|
+
# this script runs once per config key, so a caller reading several keys pays
|
|
164
|
+
# for the same constant repeatedly. Measured on CI 2026-09-25: one board build
|
|
165
|
+
# spawned `git rev-parse --show-toplevel` 21 times out of 42 git processes
|
|
166
|
+
# total, which `plan-read-shape.test.mjs` caught as the spawn count crossing
|
|
167
|
+
# its bound. The board already exports `PLOT_REPO_ROOT` (`index.ts:65`) and
|
|
168
|
+
# `plot-deliver.sh` and `plot-issue-status.sh` already read it.
|
|
169
|
+
#
|
|
170
|
+
# IT MUST BE A DIRECTORY, and a wrong one falls back rather than failing: an
|
|
171
|
+
# exported stale path would otherwise make every config read answer from a
|
|
172
|
+
# repository that is not this one, silently. Asking git is the safe answer and
|
|
173
|
+
# stays the default for every caller that offers nothing.
|
|
174
|
+
if [ -n "${PLOT_REPO_ROOT:-}" ] && [ -d "$PLOT_REPO_ROOT" ]; then
|
|
175
|
+
root="$PLOT_REPO_ROOT"
|
|
176
|
+
else
|
|
177
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
|
|
178
|
+
fi
|
|
146
179
|
|
|
147
180
|
# Find the first repo-root file that contains a ## Plot Config section.
|
|
148
181
|
# CLAUDE.md wins for backwards compatibility; AGENTS.md is the modern fallback.
|
package/plot-deliver.sh
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
# --who the name recorded in the `Delivered:` line (default: git user.name)
|
|
6
6
|
# <slug> the plan to deliver
|
|
7
7
|
# Output: one `step:` line per step, then a machine-countable summary:
|
|
8
|
-
# summary: phase=flipped record=written index=moved sprint=updated push=clean
|
|
8
|
+
# summary: phase=flipped record=written index=moved sprint=updated push=clean tracker=none
|
|
9
|
+
# `tracker=` is plot-issue-status.sh's outcome (none|written|no-target|
|
|
10
|
+
# unaskable|failed), or `skipped` when the push was rejected. It never
|
|
11
|
+
# changes the exit code: a failed status write is reported, not raised.
|
|
9
12
|
# Exit 0 when the plan is Delivered on the default branch (whether this
|
|
10
13
|
# run did the work or found it already done); 1 on a refusal or a
|
|
11
14
|
# failure, with the reason on stderr.
|
|
@@ -654,6 +657,12 @@ git -C "$tmpwt" add -- "${ACTIVE_DIR#/}" >/dev/null 2>&1 || true
|
|
|
654
657
|
git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
|
|
655
658
|
[ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
|
|
656
659
|
|
|
660
|
+
# THE BOOKED PLAN, KEPT FOR THE TRACKER. The booking worktree is removed on
|
|
661
|
+
# every exit below, and the working tree may still read `Approved`; the issue
|
|
662
|
+
# status is decided from the file that reached the default branch.
|
|
663
|
+
booked_plan=$(mktemp "${TMPDIR:-/tmp}/plot-deliver-plan.XXXXXX")
|
|
664
|
+
cp "$tmpwt/$rel" "$booked_plan" 2>/dev/null || : > "$booked_plan"
|
|
665
|
+
|
|
657
666
|
if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
|
|
658
667
|
# THE IDEMPOTENT EXIT. Everything this run would have written was already
|
|
659
668
|
# on the default branch, so there is nothing to push and nothing wrong.
|
|
@@ -694,13 +703,25 @@ else
|
|
|
694
703
|
echo "plot-deliver: the delivery is committed on '$bookbr' but could not reach $MAIN." >&2
|
|
695
704
|
echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
|
|
696
705
|
git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
|
|
697
|
-
|
|
706
|
+
rm -f "$booked_plan"
|
|
707
|
+
# NOTHING REACHED THE DEFAULT BRANCH, so no status is owed yet.
|
|
708
|
+
echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=skipped"
|
|
698
709
|
exit 1
|
|
699
710
|
fi
|
|
700
711
|
fi
|
|
701
712
|
fi
|
|
702
713
|
|
|
703
|
-
|
|
714
|
+
# THE TRACKER HEARS AFTER THE DELIVERY LANDED, and never decides it. The plan is
|
|
715
|
+
# delivered; the tracker holds a copy of one fact about it. Every outcome of
|
|
716
|
+
# plot-issue-status.sh, a failed write and an unreadable bundle included, is a
|
|
717
|
+
# report on the summary line and leaves this script's exit code alone.
|
|
718
|
+
tracker_out=$(bash "$script_dir/plot-issue-status.sh" "$booked_plan" 2>&1)
|
|
719
|
+
printf '%s\n' "$tracker_out" | grep -v '^summary: ' | sed '/^$/d; s/^/ tracker: /'
|
|
720
|
+
tracker_report=$(printf '%s' "$tracker_out" | sed -n 's/^summary: tracker=\([a-z-]*\).*/\1/p' | tail -1)
|
|
721
|
+
[ -n "$tracker_report" ] || tracker_report="failed"
|
|
722
|
+
rm -f "$booked_plan"
|
|
723
|
+
|
|
724
|
+
echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=$tracker_report"
|
|
704
725
|
# THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
|
|
705
726
|
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
|
|
706
727
|
# interrupted run can be repeated on the same licence: this script documents
|
package/plot-dispatch.sh
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# [--max N] [--allow-local] <slug>
|
|
5
5
|
# plot-dispatch.sh --start [N] [--dry-run]
|
|
6
6
|
# plot-dispatch.sh --migrate [--yes] [--max N]
|
|
7
|
+
# plot-dispatch.sh --release <branch>
|
|
7
8
|
# --status list fleet worktrees with worker pid, liveness, and last log
|
|
8
9
|
# line; then exit. Works regardless of plan phase.
|
|
9
10
|
# --stop <br> stop the worker on <br> (branch required — never "all").
|
|
@@ -23,6 +24,18 @@
|
|
|
23
24
|
# word), on a live worker, and on a PLOT-BLOCKED marker. The
|
|
24
25
|
# worktree is inherited exactly as it stands — uncommitted work
|
|
25
26
|
# is what a stall leaves behind, and this must not destroy it.
|
|
27
|
+
# --release <br>
|
|
28
|
+
# return an ABANDONED slice to the queue: clear the `branch` of
|
|
29
|
+
# every agent manifest naming <br>, then delete the claim ref
|
|
30
|
+
# origin/<br>. --stop ends a worker and KEEPS the claim, because
|
|
31
|
+
# stopping is not abandoning; --release is the act that gives the
|
|
32
|
+
# slice up. Deleting the ref by hand is not the same act: the
|
|
33
|
+
# manifest still names the branch. Branch required. Refuses on a
|
|
34
|
+
# PR (open or merged), a host it cannot ask, a live worker (named
|
|
35
|
+
# by pid), real work (a file-changing commit on origin/<br>, or
|
|
36
|
+
# unpushed commits or uncommitted changes on the local desk), and
|
|
37
|
+
# a PLOT-BLOCKED marker. A refusal writes nothing. The desk is
|
|
38
|
+
# never touched.
|
|
26
39
|
# --migrate move legacy worktrees into the configured `Worktree root:`. An
|
|
27
40
|
# idle worktree (no live worker, no unlanded work) is moved; a
|
|
28
41
|
# busy one is skipped with the reason. Requires a `Worktree root:`
|
|
@@ -145,8 +158,10 @@
|
|
|
145
158
|
# think they won. Git is the lock only when the refs actually diverge.
|
|
146
159
|
# - Worktrees are adopted, never duplicated. A dispatcher that dies halfway
|
|
147
160
|
# through a fan-out is safe to re-run.
|
|
148
|
-
# -
|
|
149
|
-
# tell a deliberately abandoned claim from a dead worker.
|
|
161
|
+
# - A fan-out deletes nothing. Cleanup belongs to /plot-reconcile, which can
|
|
162
|
+
# tell a deliberately abandoned claim from a dead worker. The one deletion
|
|
163
|
+
# here is `--release <branch>`, which a person runs after making that call,
|
|
164
|
+
# and which refuses wherever the claim is not abandoned.
|
|
150
165
|
# - The `Started:` record is booked on the DEFAULT BRANCH, after the claims,
|
|
151
166
|
# and only for branches this run newly claimed. A re-run books nothing it
|
|
152
167
|
# merely re-adopted. If the booking cannot be pushed, the fan-out stands
|
|
@@ -191,6 +206,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
191
206
|
# shellcheck source=plot-default-branch.sh
|
|
192
207
|
. "$script_dir/plot-default-branch.sh"
|
|
193
208
|
|
|
209
|
+
# The ONE writer of a manifest's empty `branch` — `clear_manifest_branch`,
|
|
210
|
+
# shared with `plot-worker-loop.sh`. `--release` clears an abandoned slice's
|
|
211
|
+
# assignment with it rather than with a second writer.
|
|
212
|
+
# shellcheck source=plot-agent-manifest.sh
|
|
213
|
+
. "$script_dir/plot-agent-manifest.sh"
|
|
214
|
+
|
|
194
215
|
# ---------------------------------------------------------------------------
|
|
195
216
|
# WHERE THE WORKTREES LIVE, and by what name
|
|
196
217
|
# ---------------------------------------------------------------------------
|
|
@@ -239,6 +260,7 @@ no_brief=0
|
|
|
239
260
|
mode=dispatch
|
|
240
261
|
stop_branch=""
|
|
241
262
|
restart_branch=""
|
|
263
|
+
release_branch=""
|
|
242
264
|
# EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
|
|
243
265
|
# number and the argument for it; a literal here would be a second copy of a
|
|
244
266
|
# decision that has one home. It is filled from the rule below.
|
|
@@ -275,6 +297,9 @@ while [ $# -gt 0 ]; do
|
|
|
275
297
|
# plan for feature/x", which describes neither what was asked nor what
|
|
276
298
|
# went wrong. The branch is consumed only when it looks like one.
|
|
277
299
|
--restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
|
|
300
|
+
# The same rule once more: a bare `--release <slug>` must not delete a ref
|
|
301
|
+
# named after a plan slug.
|
|
302
|
+
--release) mode=release; case "${2:-}" in */*) release_branch="$2"; shift ;; esac ;;
|
|
278
303
|
# `--start [N]` brings FREE agents into existence — registered, waiting, and
|
|
279
304
|
# holding no slice. The count is OPTIONAL and only a bare number is consumed,
|
|
280
305
|
# the same rule `--stop` and `--restart` apply to a branch: a value that does
|
|
@@ -312,10 +337,11 @@ while [ $# -gt 0 ]; do
|
|
|
312
337
|
shift ;;
|
|
313
338
|
# THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
|
|
314
339
|
# does; adding `--agent` above pushed that line from 59 to 69, and
|
|
315
|
-
# documenting the plan-declared kind pushed it from 69 to 78
|
|
340
|
+
# documenting the plan-declared kind pushed it from 69 to 78, and
|
|
341
|
+
# `--release` pushed it from 78 to 91. Two records
|
|
316
342
|
# of one fact, and nothing compares them — a stale number here silently
|
|
317
343
|
# truncates the help rather than failing, so it is checked by a test.
|
|
318
|
-
-h|--help) sed -n '2,
|
|
344
|
+
-h|--help) sed -n '2,91p' "$0"; exit 0 ;;
|
|
319
345
|
*) slug="$1" ;;
|
|
320
346
|
esac
|
|
321
347
|
shift
|
|
@@ -428,6 +454,23 @@ json_escape() {
|
|
|
428
454
|
#
|
|
429
455
|
# Written to a temp file and moved into place, so a scan reading the directory
|
|
430
456
|
# never sees a half-written manifest. `mv` within one directory is atomic.
|
|
457
|
+
# The directory the `Agent registry` key names, resolved against a repo root.
|
|
458
|
+
#
|
|
459
|
+
# The case split is `resolve_wt_root`'s: absolute taken as given, relative
|
|
460
|
+
# joined onto the repo root, trailing slash trimmed as pure string work because
|
|
461
|
+
# the directory need not exist yet. One resolver, read by `start_worker` when it
|
|
462
|
+
# writes a manifest and by `--release` when it clears one, so the two cannot
|
|
463
|
+
# look in different places.
|
|
464
|
+
agent_registry_dir() { # $1=repo_root → prints the directory
|
|
465
|
+
local dir
|
|
466
|
+
dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
|
|
467
|
+
case "$dir" in
|
|
468
|
+
/*) ;;
|
|
469
|
+
*) dir="$1/$dir" ;;
|
|
470
|
+
esac
|
|
471
|
+
printf '%s' "${dir%/}"
|
|
472
|
+
}
|
|
473
|
+
|
|
431
474
|
write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
|
|
432
475
|
local out="$1" tmp="$1.plot-tmp"
|
|
433
476
|
{
|
|
@@ -1119,12 +1162,7 @@ start_worker() {
|
|
|
1119
1162
|
# resolving a configured directory is a second way to be wrong.
|
|
1120
1163
|
local session manifest_dir
|
|
1121
1164
|
session=$(plot_session_id)
|
|
1122
|
-
manifest_dir=$("$
|
|
1123
|
-
case "$manifest_dir" in
|
|
1124
|
-
/*) ;;
|
|
1125
|
-
*) manifest_dir="$repo_root/$manifest_dir" ;;
|
|
1126
|
-
esac
|
|
1127
|
-
manifest_dir="${manifest_dir%/}"
|
|
1165
|
+
manifest_dir=$(agent_registry_dir "$repo_root")
|
|
1128
1166
|
mkdir -p "$manifest_dir" 2>/dev/null || true
|
|
1129
1167
|
# `printf` per field with no interpretation: a command containing quotes,
|
|
1130
1168
|
# newlines or backslashes must survive into valid JSON, and this is the one
|
|
@@ -1171,8 +1209,9 @@ start_worker() {
|
|
|
1171
1209
|
echo " refusing to start $branch — its agent manifest could not be written:"
|
|
1172
1210
|
echo " $manifest_dir/$session.json"
|
|
1173
1211
|
echo " An unregistered worker cannot be seen, stopped or reaped, and holds"
|
|
1174
|
-
echo " a claim
|
|
1175
|
-
echo " fix the path above (see the 'Agent registry'
|
|
1212
|
+
echo " a claim only 'plot-dispatch.sh --release $branch' can give up. The worktree"
|
|
1213
|
+
echo " and claim are untouched — fix the path above (see the 'Agent registry'"
|
|
1214
|
+
echo " key) and dispatch again."
|
|
1176
1215
|
return 1
|
|
1177
1216
|
fi
|
|
1178
1217
|
# TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
|
|
@@ -1699,7 +1738,8 @@ if [ "$mode" = "stop" ]; then
|
|
|
1699
1738
|
exit 1; }
|
|
1700
1739
|
# The worktree and its claim are left in place: the branch is still taken,
|
|
1701
1740
|
# and deleting either would be the kind of write this design avoids.
|
|
1702
|
-
echo " worktree kept at $wt — the claim stands until you release it"
|
|
1741
|
+
echo " worktree kept at $wt — the claim stands until you release it:"
|
|
1742
|
+
echo " plot-dispatch.sh --release $stop_branch"
|
|
1703
1743
|
;;
|
|
1704
1744
|
finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
|
|
1705
1745
|
*) echo "$stop_branch has no worker" ;;
|
|
@@ -1807,6 +1847,232 @@ if [ "$mode" = "restart" ]; then
|
|
|
1807
1847
|
exit 0
|
|
1808
1848
|
fi
|
|
1809
1849
|
|
|
1850
|
+
if [ "$mode" = "release" ]; then
|
|
1851
|
+
# THE COUNTERPART TO --stop THAT NOBODY WROTE. `--stop` ends a worker and
|
|
1852
|
+
# keeps the claim, because stopping is not abandoning. This is the act that
|
|
1853
|
+
# gives an abandoned slice back to the queue.
|
|
1854
|
+
#
|
|
1855
|
+
# THE ASSIGNMENT HAS TWO RECORDS, and this clears both. The remote claim ref
|
|
1856
|
+
# is what the scan and the registry's queue read as *somebody took this*; the
|
|
1857
|
+
# agent manifest's `branch` field is what the registry wrote when it handed
|
|
1858
|
+
# the slice over. Deleting only the ref — the repair `plot-reap.sh` and
|
|
1859
|
+
# `plot-reconcile-scan.sh` leave to a person — leaves the manifest naming a
|
|
1860
|
+
# slice the queue now offers to somebody else. Measured 2026-09-26:
|
|
1861
|
+
# `feature/the-board-filters-to-my-work` was handed out twice after exactly
|
|
1862
|
+
# that hand repair, and the second agent abandoned a desk with unpushed work.
|
|
1863
|
+
#
|
|
1864
|
+
# BEFORE THE PHASE GATE, beside --stop and --restart: a claimed branch is
|
|
1865
|
+
# work in flight whatever the plan's phase now says.
|
|
1866
|
+
if [ -z "$release_branch" ]; then
|
|
1867
|
+
echo "plot-dispatch: --release needs a branch name, e.g. --release feature/x" >&2
|
|
1868
|
+
echo " A slug is not enough: which claim is abandoned is your call, and" >&2
|
|
1869
|
+
echo " a release deletes a ref that cannot be re-created." >&2
|
|
1870
|
+
exit 1
|
|
1871
|
+
fi
|
|
1872
|
+
br="$release_branch"
|
|
1873
|
+
MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
|
|
1874
|
+
[ -n "$MAIN" ] || MAIN=$(default_branch)
|
|
1875
|
+
if [ -z "$MAIN" ]; then
|
|
1876
|
+
echo "plot-dispatch: cannot resolve the default branch — refusing to release $br." >&2
|
|
1877
|
+
echo " Nothing was written." >&2
|
|
1878
|
+
exit 1
|
|
1879
|
+
fi
|
|
1880
|
+
|
|
1881
|
+
# THE REMOTE IS READ FRESH. A stale remote-tracking ref would hide commits a
|
|
1882
|
+
# worker pushed since the last fetch, and those are the real work this must
|
|
1883
|
+
# refuse on. A fetch that fails is a remote this cannot read.
|
|
1884
|
+
if ! git fetch -q --prune origin </dev/null 2>/dev/null; then
|
|
1885
|
+
echo "plot-dispatch: cannot fetch origin — refusing to release $br." >&2
|
|
1886
|
+
echo " Without a fresh reading this cannot tell a claim from pushed work." >&2
|
|
1887
|
+
echo " Nothing was written." >&2
|
|
1888
|
+
exit 1
|
|
1889
|
+
fi
|
|
1890
|
+
ref_present=0
|
|
1891
|
+
git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1 && ref_present=1
|
|
1892
|
+
|
|
1893
|
+
# 1. A PULL REQUEST, ASKED FIRST — `handover_refusal`'s order and for its
|
|
1894
|
+
# reason: a worker's exit state says nothing about whether its work reached
|
|
1895
|
+
# review. A merged branch's ref belongs to `plot-release-refs.sh`; an open
|
|
1896
|
+
# one is work under review.
|
|
1897
|
+
#
|
|
1898
|
+
# UNREACHABLE IS NOT "NO PR". `reached_review` reads a failed call as no PR,
|
|
1899
|
+
# which is right for a rendering and wrong here: a deleted ref cannot be
|
|
1900
|
+
# re-created, so a host that cannot be asked refuses. `--offline` promises no
|
|
1901
|
+
# host call, so it refuses too.
|
|
1902
|
+
if [ -n "$offline" ]; then
|
|
1903
|
+
echo "plot-dispatch: --release asks the host whether $br has a PR, and --offline forbids that — refusing." >&2
|
|
1904
|
+
echo " Nothing was written." >&2
|
|
1905
|
+
exit 1
|
|
1906
|
+
fi
|
|
1907
|
+
if ! pr_json=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
|
|
1908
|
+
echo "plot-dispatch: the host could not be asked whether $br has a PR — refusing." >&2
|
|
1909
|
+
echo " A release deletes a ref that cannot be re-created, so silence is not" >&2
|
|
1910
|
+
echo " permission. Check the host (plot-host.sh pr-state $br) and retry." >&2
|
|
1911
|
+
echo " Nothing was written." >&2
|
|
1912
|
+
exit 1
|
|
1913
|
+
fi
|
|
1914
|
+
pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
|
|
1915
|
+
pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
|
|
1916
|
+
# ANY MERGED PR, NOT ONLY THE NEWEST — `pr-merged` reads `mergedAt` over every
|
|
1917
|
+
# PR for the branch, because a newer unmerged PR can mask a merged one.
|
|
1918
|
+
merged_answer=$("$script_dir/plot-host.sh" pr-merged "$br" </dev/null 2>/dev/null) || merged_answer=unknown
|
|
1919
|
+
case "$pr_state:$merged_answer" in
|
|
1920
|
+
OPEN:*|MERGED:*|*:merged)
|
|
1921
|
+
echo "plot-dispatch: $br has a pull request (#${pr_num:-?}, ${pr_state:-MERGED}) — refusing." >&2
|
|
1922
|
+
echo " An open PR is work under review; a merged branch's ref is released by" >&2
|
|
1923
|
+
echo " plot-release-refs.sh when its plan is delivered. Nothing was written." >&2
|
|
1924
|
+
exit 1
|
|
1925
|
+
;;
|
|
1926
|
+
*:unknown|*:)
|
|
1927
|
+
echo "plot-dispatch: the host could not say whether $br ever merged — refusing." >&2
|
|
1928
|
+
echo " Check it (plot-host.sh pr-merged $br) and retry. Nothing was written." >&2
|
|
1929
|
+
exit 1
|
|
1930
|
+
;;
|
|
1931
|
+
esac
|
|
1932
|
+
|
|
1933
|
+
# THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
|
|
1934
|
+
# other verb here follows. A manifest naming this branch may name a desk git
|
|
1935
|
+
# does not list (removed by hand), so its `worktree` is the fallback.
|
|
1936
|
+
release_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
|
|
1937
|
+
/^worktree / { path = substr($0, 10) }
|
|
1938
|
+
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1939
|
+
registry_dir=$(agent_registry_dir "$repo_root_early")
|
|
1940
|
+
named_manifests=()
|
|
1941
|
+
if [ -d "$registry_dir" ]; then
|
|
1942
|
+
for m in "$registry_dir"/*.json; do
|
|
1943
|
+
[ -f "$m" ] || continue
|
|
1944
|
+
m_branch=$(node -e '
|
|
1945
|
+
try {
|
|
1946
|
+
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
1947
|
+
process.stdout.write(typeof m.branch === "string" ? m.branch : "");
|
|
1948
|
+
} catch { process.stdout.write(""); }
|
|
1949
|
+
' "$m" 2>/dev/null)
|
|
1950
|
+
[ "$m_branch" = "$br" ] || continue
|
|
1951
|
+
named_manifests+=("$m")
|
|
1952
|
+
if [ -z "$release_wt" ]; then
|
|
1953
|
+
m_wt=$(node -e '
|
|
1954
|
+
try {
|
|
1955
|
+
const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
1956
|
+
process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
|
|
1957
|
+
} catch { process.stdout.write(""); }
|
|
1958
|
+
' "$m" 2>/dev/null)
|
|
1959
|
+
[ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
|
|
1960
|
+
fi
|
|
1961
|
+
done
|
|
1962
|
+
fi
|
|
1963
|
+
[ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
|
|
1964
|
+
|
|
1965
|
+
# 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
|
|
1966
|
+
# classifier, never `pgrep` by name. A live pid means somebody is working,
|
|
1967
|
+
# and the one case where a claim is not abandoned.
|
|
1968
|
+
if [ -n "$release_wt" ]; then
|
|
1969
|
+
release_state=$(plot_worker_state "$release_wt" "" | cut -f1,2)
|
|
1970
|
+
case "$release_state" in
|
|
1971
|
+
running*)
|
|
1972
|
+
echo "plot-dispatch: a worker is alive on $br (pid $(printf '%s' "$release_state" | cut -f2)) — refusing." >&2
|
|
1973
|
+
echo " A live worker has not abandoned its claim. Stop it first if you mean to:" >&2
|
|
1974
|
+
echo " plot-dispatch.sh --stop $br" >&2
|
|
1975
|
+
echo " Nothing was written." >&2
|
|
1976
|
+
exit 1
|
|
1977
|
+
;;
|
|
1978
|
+
esac
|
|
1979
|
+
fi
|
|
1980
|
+
|
|
1981
|
+
# 3. REAL WORK. A claim is an EMPTY commit on the default branch's tip, so
|
|
1982
|
+
# it changes no file; `-- .` limits the count to commits that change one,
|
|
1983
|
+
# which is what separates a claim-only ref from pushed work. The local desk
|
|
1984
|
+
# is asked too, because the measured victim held its work in UNPUSHED
|
|
1985
|
+
# commits and uncommitted files that no remote reading can see. A count of
|
|
1986
|
+
# commits, not an ancestry verdict: a count above zero only ever REFUSES.
|
|
1987
|
+
if [ "$ref_present" = 1 ]; then
|
|
1988
|
+
remote_work=$(git rev-list --count "refs/remotes/origin/$MAIN..refs/remotes/origin/$br" -- . </dev/null 2>/dev/null) || remote_work=""
|
|
1989
|
+
if [ -z "$remote_work" ]; then
|
|
1990
|
+
echo "plot-dispatch: cannot count the commits on origin/$br — refusing." >&2
|
|
1991
|
+
echo " Nothing was written." >&2
|
|
1992
|
+
exit 1
|
|
1993
|
+
fi
|
|
1994
|
+
if [ "$remote_work" -gt 0 ]; then
|
|
1995
|
+
echo "plot-dispatch: origin/$br carries $remote_work commit(s) that change files — refusing." >&2
|
|
1996
|
+
echo " That is work, not an abandoned claim. Review it, or open its PR:" >&2
|
|
1997
|
+
echo " plot-open-pr.sh $br" >&2
|
|
1998
|
+
echo " Nothing was written." >&2
|
|
1999
|
+
exit 1
|
|
2000
|
+
fi
|
|
2001
|
+
fi
|
|
2002
|
+
if [ -n "$release_wt" ]; then
|
|
2003
|
+
desk_dirty=$(plot_worker_dirty "$release_wt")
|
|
2004
|
+
if [ "$ref_present" = 1 ]; then desk_base="refs/remotes/origin/$br"; else desk_base="refs/remotes/origin/$MAIN"; fi
|
|
2005
|
+
# Local commits the remote lacks exist nowhere else, so any refuses.
|
|
2006
|
+
desk_unpushed=$(git -C "$release_wt" rev-list --count "$desk_base..HEAD" -- . </dev/null 2>/dev/null) || desk_unpushed=0
|
|
2007
|
+
if [ -n "$desk_dirty" ] || [ "${desk_unpushed:-0}" -gt 0 ]; then
|
|
2008
|
+
echo "plot-dispatch: the desk at $release_wt holds work for $br — refusing." >&2
|
|
2009
|
+
[ "${desk_unpushed:-0}" -gt 0 ] && echo " $desk_unpushed unpushed commit(s) that change files" >&2
|
|
2010
|
+
[ -n "$desk_dirty" ] && echo " uncommitted changes: $(printf '%s' "$desk_dirty" | tr '\n' ' ')" >&2
|
|
2011
|
+
echo " Releasing would hand the slice to an agent that cannot see this work." >&2
|
|
2012
|
+
echo " Push it, or hand the branch to a new worker: plot-dispatch.sh --restart $br" >&2
|
|
2013
|
+
echo " Nothing was written." >&2
|
|
2014
|
+
exit 1
|
|
2015
|
+
fi
|
|
2016
|
+
|
|
2017
|
+
# 4. A PLOT-BLOCKED MARKER: the agent is waiting on a person, and a slice
|
|
2018
|
+
# waiting on an answer is not abandoned. Asked through
|
|
2019
|
+
# `plot_worker_blocked_file`, where the marker's spelling lives.
|
|
2020
|
+
if marker=$(plot_worker_blocked_file "$release_wt") && [ -n "$marker" ]; then
|
|
2021
|
+
echo "plot-dispatch: $br is blocked on a question — refusing." >&2
|
|
2022
|
+
echo " the question is in $release_wt/$marker" >&2
|
|
2023
|
+
echo " Answer it and delete the marker; the agent's slice is not abandoned." >&2
|
|
2024
|
+
echo " Nothing was written." >&2
|
|
2025
|
+
exit 1
|
|
2026
|
+
fi
|
|
2027
|
+
fi
|
|
2028
|
+
|
|
2029
|
+
if [ "$ref_present" = 0 ] && [ "${#named_manifests[@]}" -eq 0 ]; then
|
|
2030
|
+
echo "$br holds no claim: origin/$br does not exist and no manifest names it."
|
|
2031
|
+
echo " Nothing to release."
|
|
2032
|
+
exit 0
|
|
2033
|
+
fi
|
|
2034
|
+
|
|
2035
|
+
# THE MANIFESTS FIRST, THEN THE REF. If the ref deletion fails, the manifest
|
|
2036
|
+
# is already free and the ref still locks, so the scan still reads the slice
|
|
2037
|
+
# as claimed and nothing hands it out twice. The reverse order would leave the
|
|
2038
|
+
# measured failure on disk: no ref, and a manifest still naming the branch.
|
|
2039
|
+
released_manifests=0
|
|
2040
|
+
if [ "${#named_manifests[@]}" -eq 0 ]; then
|
|
2041
|
+
echo " no manifest in $registry_dir names $br"
|
|
2042
|
+
fi
|
|
2043
|
+
for m in ${named_manifests[@]+"${named_manifests[@]}"}; do
|
|
2044
|
+
if ! clear_manifest_branch "$m"; then
|
|
2045
|
+
echo "plot-dispatch: could not clear the assignment in $m — refusing to delete the ref." >&2
|
|
2046
|
+
echo " The claim on origin/$br stands; fix the file and run --release again." >&2
|
|
2047
|
+
exit 1
|
|
2048
|
+
fi
|
|
2049
|
+
released_manifests=$((released_manifests + 1))
|
|
2050
|
+
echo " cleared the assignment in $m"
|
|
2051
|
+
done
|
|
2052
|
+
|
|
2053
|
+
if [ "$ref_present" = 1 ]; then
|
|
2054
|
+
if ! git push -q origin --delete "$br" </dev/null 2>/dev/null; then
|
|
2055
|
+
# VERIFIED, NOT TRUSTED: the host has returned 503 on a push that landed.
|
|
2056
|
+
git fetch -q --prune origin </dev/null 2>/dev/null || true
|
|
2057
|
+
if git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1; then
|
|
2058
|
+
echo "plot-dispatch: could not delete origin/$br — the claim still locks the slice." >&2
|
|
2059
|
+
echo " The manifests are already free, so nothing hands it out twice. Retry:" >&2
|
|
2060
|
+
echo " plot-dispatch.sh --release $br" >&2
|
|
2061
|
+
exit 1
|
|
2062
|
+
fi
|
|
2063
|
+
fi
|
|
2064
|
+
echo " deleted the claim ref origin/$br"
|
|
2065
|
+
else
|
|
2066
|
+
echo " origin/$br does not exist — only the assignment needed releasing"
|
|
2067
|
+
fi
|
|
2068
|
+
if [ -n "$release_wt" ]; then
|
|
2069
|
+
echo " the desk at $release_wt still holds $br and is left as it is"
|
|
2070
|
+
fi
|
|
2071
|
+
echo "released $br — the slice returns to the queue"
|
|
2072
|
+
echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent)"
|
|
2073
|
+
exit 0
|
|
2074
|
+
fi
|
|
2075
|
+
|
|
1810
2076
|
if [ "$mode" = "start" ]; then
|
|
1811
2077
|
# THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
|
|
1812
2078
|
# registry matches them to free agents, but until this verb existed nothing
|
|
@@ -3063,6 +3329,10 @@ append_started_line() { # $1=file $2=date $3=who $4=branch
|
|
|
3063
3329
|
insert = start
|
|
3064
3330
|
for (i = start + 1; i <= n; i++) {
|
|
3065
3331
|
if (lines[i] ~ /^##[ \t]/) break
|
|
3332
|
+
# An HTML comment ends the writable region. Checked BEFORE the
|
|
3333
|
+
# placeholder arms so a commented-out `- **Started:**` template line
|
|
3334
|
+
# is never mistaken for the slot to fill.
|
|
3335
|
+
if (lines[i] ~ /<!--/) break
|
|
3066
3336
|
if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
|
|
3067
3337
|
if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
|
|
3068
3338
|
}
|