@plot-pm/board 0.16.2 → 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/dist/board-server.mjs +162 -139
- package/package.json +1 -3
- package/plot-agent-manifest.sh +111 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-build-monitor.sh +49 -4
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +263 -91
- package/plot-fleet-scan.sh +746 -62
- package/plot-host.sh +273 -18
- package/plot-monitor-subject.sh +55 -3
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +61 -28
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +31 -3
- package/plot-transcript-quiet.sh +3 -1
- package/plot-worker-state.sh +611 -24
- package/plot-resolve-artifact.sh +0 -423
- package/plot-worker-monitor.sh +0 -705
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "0.16.
|
|
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",
|
|
@@ -39,11 +39,9 @@
|
|
|
39
39
|
"plot-pr-merged.sh",
|
|
40
40
|
"plot-reap.sh",
|
|
41
41
|
"plot-release-refs.sh",
|
|
42
|
-
"plot-resolve-artifact.sh",
|
|
43
42
|
"plot-state-receipt.sh",
|
|
44
43
|
"plot-tmp.sh",
|
|
45
44
|
"plot-transcript-quiet.sh",
|
|
46
|
-
"plot-worker-monitor.sh",
|
|
47
45
|
"plot-worker-state.sh"
|
|
48
46
|
],
|
|
49
47
|
"scripts": {
|
package/plot-agent-manifest.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
123
|
-
#
|
|
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
|
+
}
|
package/plot-agent-monitor.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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=''
|
package/plot-approve.sh
CHANGED
|
@@ -107,12 +107,13 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
107
107
|
. "$script_dir/plot-state-receipt.sh"
|
|
108
108
|
|
|
109
109
|
dry_run=0
|
|
110
|
-
|
|
110
|
+
who_flag_given=0
|
|
111
|
+
who_flag=""
|
|
111
112
|
slug=""
|
|
112
113
|
while [ $# -gt 0 ]; do
|
|
113
114
|
case "$1" in
|
|
114
115
|
--dry-run) dry_run=1 ;;
|
|
115
|
-
--who)
|
|
116
|
+
--who) who_flag_given=1; who_flag="${2:-}"; shift ;;
|
|
116
117
|
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
|
117
118
|
-*) echo "plot-approve: unknown flag '$1'" >&2; exit 1 ;;
|
|
118
119
|
*) slug="$1" ;;
|
|
@@ -195,11 +196,40 @@ esac
|
|
|
195
196
|
# NONE means a pre-Plot-2 plan on an idea branch, which the skill documents as
|
|
196
197
|
# `pr` by default. An unrecognised value is refused rather than defaulted:
|
|
197
198
|
# "carry on" is the shape of stale assumption this whole story keeps finding.
|
|
199
|
+
#
|
|
200
|
+
# `in-session` IS NO LONGER A REFUSAL HERE, which is this slice's whole change.
|
|
201
|
+
# `plot-approve.sh --who <handle> <slug>` performs the seven mechanical steps
|
|
202
|
+
# for it exactly as it does for `pr`, because #1185 made the domain able to
|
|
203
|
+
# decide the write (`approveTransition` takes `who` and `people`). What stays a
|
|
204
|
+
# refusal is the absence of a usable reviewer: no `--who` at all, an empty one,
|
|
205
|
+
# or one `People` does not declare — the domain's `review-human` and
|
|
206
|
+
# `reviewer-undeclared` gates, asked through `decide_transition` below rather
|
|
207
|
+
# than re-implemented here. There is no `--reviewer`: a round-1 panel measured
|
|
208
|
+
# that spelling exiting 2 at the controller gate, and `--who` is the one flag
|
|
209
|
+
# this script already declares.
|
|
210
|
+
#
|
|
211
|
+
# UNDER `PLOT_UNATTENDED=1` THIS STILL REFUSES, `--who` or not. The reviewer is
|
|
212
|
+
# a human in a session that, by definition, has nobody in it under an
|
|
213
|
+
# unattended run — the same argument `plot-approve/SKILL.md` already makes for
|
|
214
|
+
# the skill's own in-session walkthrough. A default here would let the machine
|
|
215
|
+
# name the reviewer, which is exactly what `--who`'s absence of a fallback (no
|
|
216
|
+
# `PLOT_APPROVE_WHO`, no `git config user.name`) refuses for the same channel.
|
|
217
|
+
in_session=0
|
|
198
218
|
case "$review" in
|
|
199
219
|
pr|NONE) ;;
|
|
200
220
|
in-session)
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
if [ "${PLOT_UNATTENDED:-}" = "1" ]; then
|
|
222
|
+
die "plan '$slug' declares 'Review: in-session' — the reviewer is a human in the room.
|
|
223
|
+
Refusing under PLOT_UNATTENDED=1: there is nobody here to name. Approve it from a session: /plot-approve $slug"
|
|
224
|
+
fi
|
|
225
|
+
# No reviewer named: refuse before any git work. The domain's `review-human`
|
|
226
|
+
# gate says the same thing later, after a booking worktree exists; asking
|
|
227
|
+
# here keeps a refusal local and cheap. `--who` has no default.
|
|
228
|
+
if [ -z "$(printf '%s' "$who_flag" | tr -d '[:space:]')" ]; then
|
|
229
|
+
die "plan '$slug' declares 'Review: in-session' — name the reviewer with --who."
|
|
230
|
+
fi
|
|
231
|
+
in_session=1
|
|
232
|
+
;;
|
|
203
233
|
ballot)
|
|
204
234
|
die "plan '$slug' declares 'Review: ballot' — the tally is the approval.
|
|
205
235
|
A script cannot read a ballot. Approve it with /plot-approve $slug." ;;
|
|
@@ -208,71 +238,134 @@ case "$review" in
|
|
|
208
238
|
Refusing rather than treating it as 'pr' — that would approve a plan nobody discussed." ;;
|
|
209
239
|
esac
|
|
210
240
|
|
|
241
|
+
# The declared handles, for the domain's `reviewer-undeclared` gate. Read
|
|
242
|
+
# unconditionally — cheap, and a `pr` or `ballot` plan never reaches the branch
|
|
243
|
+
# that uses it — from `People`'s `handle = Spelling; handle = Spelling` form:
|
|
244
|
+
# only the text before each `=` is a handle, and `People` may be absent.
|
|
245
|
+
people_raw=$(cfg "People" "")
|
|
246
|
+
people_csv=$(printf '%s' "$people_raw" | tr ';' '\n' | while IFS= read -r entry; do
|
|
247
|
+
h="${entry%%=*}"
|
|
248
|
+
h="$(printf '%s' "$h" | sed -e 's/^[ \t]*//' -e 's/[ \t]*$//' | tr '[:upper:]' '[:lower:]')"
|
|
249
|
+
[ -n "$h" ] && printf '%s\n' "$h"
|
|
250
|
+
done | paste -sd, -)
|
|
251
|
+
[ -n "$people_csv" ] || people_csv=""
|
|
252
|
+
|
|
253
|
+
# Read even for in-session: the booking worktree below (every non-same-branch
|
|
254
|
+
# flow, in-session included) fetches and books against it.
|
|
211
255
|
MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
|
|
212
256
|
[ -n "$MAIN" ] || MAIN=$(bash "$script_dir/plot-host.sh" default-branch 2>/dev/null) || MAIN=""
|
|
213
257
|
[ -n "$MAIN" ] || MAIN="main"
|
|
214
258
|
|
|
215
|
-
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
[ -z "$p" ] && continue
|
|
226
|
-
[ "$p" = "idea" ] && continue
|
|
227
|
-
if git show-ref --verify --quiet "refs/heads/$p/$slug" \
|
|
228
|
-
|| git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
|
|
229
|
-
pr_branch="$p/$slug"
|
|
230
|
-
break
|
|
231
|
-
fi
|
|
232
|
-
done
|
|
259
|
+
if [ "$in_session" = 1 ]; then
|
|
260
|
+
# No plan PR to read or merge: the approval IS the reviewer's go, not a host
|
|
261
|
+
# state. `same_branch` stays 0 — in-session records through the same booking
|
|
262
|
+
# worktree the `pr` flow uses, since an in-session plan has no work branch of
|
|
263
|
+
# its own to record on.
|
|
264
|
+
same_branch=0
|
|
265
|
+
pr_number=0
|
|
266
|
+
pr_state="NONE"
|
|
267
|
+
pr_draft="false"
|
|
268
|
+
echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} (in-session, no plan PR)"
|
|
233
269
|
else
|
|
234
|
-
|
|
235
|
-
|
|
270
|
+
# The PR that carries the plan. `Impl: same branch` puts plan and code on the
|
|
271
|
+
# work branch, so its PR is the WORK branch's — and it must not be merged here
|
|
272
|
+
# (it merges once, at the end, carrying the implementation with it).
|
|
273
|
+
same_branch=0
|
|
274
|
+
[ "$impl" = "same-branch" ] && same_branch=1
|
|
275
|
+
|
|
276
|
+
if [ "$same_branch" = 1 ]; then
|
|
277
|
+
pr_branch="$slug"
|
|
278
|
+
for p in $(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" | tr ',' ' '); do
|
|
279
|
+
p="${p%/}"; p="${p# }"
|
|
280
|
+
[ -z "$p" ] && continue
|
|
281
|
+
[ "$p" = "idea" ] && continue
|
|
282
|
+
if git show-ref --verify --quiet "refs/heads/$p/$slug" \
|
|
283
|
+
|| git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
|
|
284
|
+
pr_branch="$p/$slug"
|
|
285
|
+
break
|
|
286
|
+
fi
|
|
287
|
+
done
|
|
288
|
+
else
|
|
289
|
+
pr_branch="idea/$slug"
|
|
290
|
+
fi
|
|
236
291
|
|
|
237
|
-
# THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
|
|
238
|
-
# with `state: NONE` when the host answered that the branch has no PR, and
|
|
239
|
-
# non-zero when the host could not be asked: 3 for a refused or failed call
|
|
240
|
-
# (a rate limit included), 4 for a backend with no answer at all. Only the
|
|
241
|
-
# first is an absence. The other two stop here with the host's own words and
|
|
242
|
-
# name no repair to the branch, because nothing about the branch was read.
|
|
243
|
-
pr_err_file=""
|
|
244
|
-
plot_tmpfile pr_err_file approve-pr
|
|
245
|
-
pr_rc=0
|
|
246
|
-
pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
|
|
247
|
-
pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
|
|
248
|
-
if [ "$pr_rc" = 4 ]; then
|
|
249
|
-
|
|
292
|
+
# THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
|
|
293
|
+
# with `state: NONE` when the host answered that the branch has no PR, and
|
|
294
|
+
# non-zero when the host could not be asked: 3 for a refused or failed call
|
|
295
|
+
# (a rate limit included), 4 for a backend with no answer at all. Only the
|
|
296
|
+
# first is an absence. The other two stop here with the host's own words and
|
|
297
|
+
# name no repair to the branch, because nothing about the branch was read.
|
|
298
|
+
pr_err_file=""
|
|
299
|
+
plot_tmpfile pr_err_file approve-pr
|
|
300
|
+
pr_rc=0
|
|
301
|
+
pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
|
|
302
|
+
pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
|
|
303
|
+
if [ "$pr_rc" = 4 ]; then
|
|
304
|
+
die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
|
|
250
305
|
${pr_err:-The host adapter gave no reason.}
|
|
251
306
|
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."
|
|
252
|
-
elif [ "$pr_rc" != 0 ]; then
|
|
253
|
-
|
|
307
|
+
elif [ "$pr_rc" != 0 ]; then
|
|
308
|
+
die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
|
|
254
309
|
${pr_err:-The host adapter gave no reason.}
|
|
255
310
|
The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
|
|
256
|
-
fi
|
|
257
|
-
[ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
|
|
258
|
-
pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
|
|
259
|
-
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
|
|
260
|
-
pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
|
|
261
|
-
|
|
262
|
-
# --- refusal 3: the PR ------------------------------------------------------
|
|
263
|
-
case "$pr_state" in
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
311
|
+
fi
|
|
312
|
+
[ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
|
|
313
|
+
pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
|
|
314
|
+
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
|
|
315
|
+
pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
|
|
316
|
+
|
|
317
|
+
# --- refusal 3: the PR ------------------------------------------------------
|
|
318
|
+
case "$pr_state" in
|
|
319
|
+
MERGED) ;;
|
|
320
|
+
OPEN) ;;
|
|
321
|
+
CLOSED)
|
|
322
|
+
die "the plan PR for '$slug' (#$pr_number) is closed.
|
|
268
323
|
Reopen it on the host, or push '$pr_branch' again and open a new one." ;;
|
|
269
|
-
|
|
270
|
-
|
|
324
|
+
NONE|*)
|
|
325
|
+
die "no PR found for branch '$pr_branch'.
|
|
271
326
|
Push the branch: git push -u origin $pr_branch
|
|
272
327
|
Then open its PR — or run /plot-idea, which does both." ;;
|
|
273
|
-
esac
|
|
328
|
+
esac
|
|
329
|
+
fi
|
|
274
330
|
|
|
275
|
-
|
|
331
|
+
# --- refusal 4: a branch under no slice heading -----------------------------
|
|
332
|
+
#
|
|
333
|
+
# BEFORE THE MERGE, which is the whole point of its position. Step 2 merges the
|
|
334
|
+
# plan PR, and a refusal fired after that leaves the PR merged, the plan still
|
|
335
|
+
# Draft, and a refusal the operator can clear only by editing a merged plan. So
|
|
336
|
+
# it sits with refusals 1 to 3, reading the same `$meta` step 1 took, and it
|
|
337
|
+
# writes nothing.
|
|
338
|
+
#
|
|
339
|
+
# The heading is the slice's name on the board and the title of its PR
|
|
340
|
+
# (`openSlicePr` refuses `slice-unnamed`), so it is owed on the default branch
|
|
341
|
+
# before any agent starts. Without this an agent meets that refusal, writes the
|
|
342
|
+
# heading on its own branch, and the board reads `(unnamed)` until the PR
|
|
343
|
+
# merges (#1057).
|
|
344
|
+
#
|
|
345
|
+
# The rule is the domain's and it is asked, never re-implemented here:
|
|
346
|
+
# `decide_transition` asks the same one through the same bundle, so the two can
|
|
347
|
+
# never disagree about one plan.
|
|
348
|
+
transition_check_mjs="$script_dir/board/plot-transition.mjs"
|
|
349
|
+
if [ -f "$transition_check_mjs" ]; then
|
|
350
|
+
slice_err_file=""
|
|
351
|
+
plot_tmpfile slice_err_file approve-slices
|
|
352
|
+
slice_rc=0
|
|
353
|
+
printf '%s' "$meta" | node "$transition_check_mjs" --check-slices "$slug" \
|
|
354
|
+
2>"$slice_err_file" >/dev/null || slice_rc=$?
|
|
355
|
+
slice_err=$(cat "$slice_err_file" 2>/dev/null); rm -f "$slice_err_file"
|
|
356
|
+
if [ "$slice_rc" = 1 ]; then
|
|
357
|
+
die "$(printf '%s' "$slice_err" | cut -f2-)
|
|
358
|
+
The plan was not approved, nothing was merged and its phase is unchanged."
|
|
359
|
+
elif [ "$slice_rc" != 0 ]; then
|
|
360
|
+
die "cannot read the slices of '$plan_file' (plot-transition.mjs --check-slices exited $slice_rc).
|
|
361
|
+
${slice_err:-The bundle gave no reason.}
|
|
362
|
+
Refusing rather than approving a plan whose slice headings were never read."
|
|
363
|
+
fi
|
|
364
|
+
else
|
|
365
|
+
echo "plot-approve: cannot find $transition_check_mjs — slice headings went unchecked. Run 'pnpm build:board'." >&2
|
|
366
|
+
fi
|
|
367
|
+
|
|
368
|
+
[ "$in_session" = 1 ] || echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
|
|
276
369
|
|
|
277
370
|
# --- a DRAFT is taken out of draft, not refused ------------------------------
|
|
278
371
|
#
|
|
@@ -290,16 +383,35 @@ echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$
|
|
|
290
383
|
# mergeable on either host — and stated as its own `step:` line so a caller
|
|
291
384
|
# reading the output can see which half happened if the second one fails.
|
|
292
385
|
|
|
293
|
-
|
|
386
|
+
# `in-session` TAKES NO DEFAULT. A default here would let the machine name the
|
|
387
|
+
# reviewer, which is the one thing a script must never do for a channel that
|
|
388
|
+
# exists because a human is in the room — so `--who` alone answers, and an
|
|
389
|
+
# empty or absent one reaches the domain's `review-human` refusal below rather
|
|
390
|
+
# than silently becoming `git config user.name`. `pr` and the direct/same-branch
|
|
391
|
+
# flow keep the existing chain: `PLOT_APPROVE_WHO`, then the git identity.
|
|
392
|
+
if [ "$in_session" = 1 ]; then
|
|
393
|
+
who="$who_flag"
|
|
394
|
+
channel="in-session"
|
|
395
|
+
else
|
|
396
|
+
who="${who_flag:-${PLOT_APPROVE_WHO:-$(git config user.name 2>/dev/null || echo plot)}}"
|
|
397
|
+
fi
|
|
294
398
|
today=$(date +%Y-%m-%d)
|
|
295
399
|
|
|
296
400
|
if [ "$dry_run" = 1 ]; then
|
|
297
|
-
[ "$
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
401
|
+
if [ "$in_session" = 1 ]; then
|
|
402
|
+
echo "step: would flip Phase → Approved and fill Approved: $today, $who, in-session"
|
|
403
|
+
echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
|
|
404
|
+
echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
|
|
405
|
+
echo "step: would append a row to .plot/state/in-session-approvals.tsv"
|
|
406
|
+
echo "summary: merged=skipped-in-session phase=would record=would holds=would sprint=would push=would"
|
|
407
|
+
else
|
|
408
|
+
[ "$pr_draft" = "true" ] && echo "step: would mark PR #$pr_number ready for review"
|
|
409
|
+
echo "step: would merge PR #$pr_number"
|
|
410
|
+
echo "step: would flip Phase → Approved and fill Approved: $today, $who, plan-PR #$pr_number merged"
|
|
411
|
+
echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
|
|
412
|
+
echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
|
|
413
|
+
echo "summary: merged=would phase=would record=would holds=would sprint=would push=would"
|
|
414
|
+
fi
|
|
303
415
|
exit 0
|
|
304
416
|
fi
|
|
305
417
|
|
|
@@ -313,7 +425,11 @@ fi
|
|
|
313
425
|
# Merge commits, not squash: plan refinement history is the context a later
|
|
314
426
|
# reader wants. `--delete-branch` retires idea/<slug>, which has no further job.
|
|
315
427
|
merged_report="already"
|
|
316
|
-
if [ "$
|
|
428
|
+
if [ "$in_session" = 1 ]; then
|
|
429
|
+
# No plan PR exists for this channel: the approval is the reviewer's go,
|
|
430
|
+
# read nowhere on a host.
|
|
431
|
+
merged_report="skipped-in-session"
|
|
432
|
+
elif [ "$same_branch" = 1 ]; then
|
|
317
433
|
# Plan and code ride one branch; the PR merges once, at the end, and merging
|
|
318
434
|
# it here would land an unfinished implementation on the default branch.
|
|
319
435
|
merged_report="skipped-same-branch"
|
|
@@ -513,16 +629,30 @@ decide_transition() { # $1=file $2=channel → prints "<Phase>\t<record>\t<writ
|
|
|
513
629
|
|| { echo "plot-approve: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
|
|
514
630
|
m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
|
|
515
631
|
[ -n "$m" ] || { echo "plot-approve: cannot parse $f — refusing rather than guessing." >&2; return 1; }
|
|
516
|
-
|
|
632
|
+
# THE SLICES TRAVEL WITH THE TRANSITION, not only with the early check above.
|
|
633
|
+
# Measured 2026-10-02 by disabling that check: this call approved an unnamed
|
|
634
|
+
# plan outright — merged, flipped and recorded — because the field line
|
|
635
|
+
# carries no slices and the domain reads an absent reading as unmeasured. So
|
|
636
|
+
# the rule is asked twice, the second time from the file this re-parses, which
|
|
637
|
+
# on the `pr` flow is the plan on the default branch.
|
|
638
|
+
#
|
|
639
|
+
# Out of band rather than a field of its own, which is why `People` widening
|
|
640
|
+
# the line to twelve did not touch this: the slices are a nested list, and
|
|
641
|
+
# `requestFrom` pads nothing.
|
|
642
|
+
local slices_file=""
|
|
643
|
+
plot_tmpfile slices_file approve-transition-slices
|
|
644
|
+
printf '%s' "$m" > "$slices_file"
|
|
645
|
+
answer=$(printf 'approve\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\t%s\n' \
|
|
517
646
|
"$slug" \
|
|
518
647
|
"$(printf '%s' "$m" | jq -r '.phase // ""')" \
|
|
519
648
|
"$(printf '%s' "$m" | jq -r '.review // ""')" \
|
|
520
649
|
"$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
|
|
521
650
|
"$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
|
|
522
651
|
"$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
|
|
523
|
-
"$today" "$who" "$channel" \
|
|
524
|
-
| node "$transition_mjs" 2>&1)
|
|
652
|
+
"$today" "$who" "$channel" "$people_csv" \
|
|
653
|
+
| node "$transition_mjs" --slices "$slices_file" 2>&1)
|
|
525
654
|
rc=$?
|
|
655
|
+
rm -f "$slices_file"
|
|
526
656
|
# Exit 1 is the domain's refusal and its sentence, tab-separated after the
|
|
527
657
|
# rule that fired. Exit 2 is this script handing it something unreadable,
|
|
528
658
|
# which no operator can act on — so it reports as the bug it is.
|
|
@@ -690,10 +820,11 @@ apply_local_writes() { # $1=root → sets phase_report record_report holds_repo
|
|
|
690
820
|
# here rather than trusted from the caller's copy, because on the `pr` flow
|
|
691
821
|
# those are different files and the plan on the default branch is the one that
|
|
692
822
|
# counts.
|
|
693
|
-
local
|
|
694
|
-
[ "$same_branch" = 1 ] &&
|
|
823
|
+
local write_channel="plan-PR #$pr_number merged"
|
|
824
|
+
[ "$same_branch" = 1 ] && write_channel="plan-PR #$pr_number reviewed"
|
|
825
|
+
[ "$in_session" = 1 ] && write_channel="$channel"
|
|
695
826
|
local decided record action recorded
|
|
696
|
-
decided=$(decide_transition "$f" "$
|
|
827
|
+
decided=$(decide_transition "$f" "$write_channel") || return 1
|
|
697
828
|
record=$(printf '%s' "$decided" | cut -f2)
|
|
698
829
|
action=$(printf '%s' "$decided" | cut -f3)
|
|
699
830
|
recorded=$(printf '%s' "$decided" | cut -f4)
|
|
@@ -791,13 +922,16 @@ else
|
|
|
791
922
|
cleanup
|
|
792
923
|
else
|
|
793
924
|
# BRANCH PROTECTION FALLBACK — the only path where a micro-PR is right.
|
|
794
|
-
# Never leave the
|
|
795
|
-
#
|
|
925
|
+
# Never leave the approved plan stranded at `Phase: Draft`: the approval
|
|
926
|
+
# is decided and irreversible once committed, so the recorded phase must
|
|
927
|
+
# follow it.
|
|
928
|
+
micro_body="Records the approval of \`$slug\` (plan-PR #$pr_number merged)."
|
|
929
|
+
[ "$in_session" = 1 ] && micro_body="Records the in-session approval of \`$slug\` by $who."
|
|
796
930
|
echo "step: push rejected — opening a micro-PR instead"
|
|
797
931
|
if git push -q origin "$bookbr" 2>/dev/null \
|
|
798
932
|
&& micro_url=$(bash "$script_dir/plot-host.sh" pr-create \
|
|
799
933
|
--title "plot: approve $slug" \
|
|
800
|
-
--body "
|
|
934
|
+
--body "$micro_body" \
|
|
801
935
|
--base "$MAIN" --head "$bookbr" 2>/dev/null) \
|
|
802
936
|
&& micro_num=$(printf '%s' "$micro_url" | sed 's#.*/##') \
|
|
803
937
|
&& bash "$script_dir/plot-host.sh" pr-merge "$micro_num" --delete-branch >/dev/null 2>&1
|
|
@@ -807,8 +941,10 @@ else
|
|
|
807
941
|
cleanup
|
|
808
942
|
else
|
|
809
943
|
push_report="rejected"
|
|
944
|
+
stranded_reason="PR #$pr_number IS MERGED"
|
|
945
|
+
[ "$in_session" = 1 ] && stranded_reason="the in-session approval IS DECIDED"
|
|
810
946
|
echo "plot-approve: the approval is committed on '$bookbr' but could not reach $MAIN." >&2
|
|
811
|
-
echo "
|
|
947
|
+
echo " $stranded_reason — the plan must not stay at Phase: Draft." >&2
|
|
812
948
|
echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
|
|
813
949
|
git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
|
|
814
950
|
echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
|
|
@@ -818,6 +954,21 @@ else
|
|
|
818
954
|
fi
|
|
819
955
|
fi
|
|
820
956
|
|
|
957
|
+
# THE LOG REPLACES A COUNT, NEVER A RECEIPT. Appended ONLY after the push
|
|
958
|
+
# landed this run — `nothing-to-commit` means an earlier run already recorded
|
|
959
|
+
# this approval (and, with it, this row), so appending again would double a
|
|
960
|
+
# count that exists to say how many in-session approvals happened, not how
|
|
961
|
+
# many times this script ran. A refused or interrupted run reaches neither
|
|
962
|
+
# this line nor a row, which is `plot-agent-settings.sh`'s rule applied here:
|
|
963
|
+
# read the exit code, not the emptiness.
|
|
964
|
+
if [ "$in_session" = 1 ] && [ "$push_report" != "nothing-to-commit" ] && [ "$push_report" != "n/a" ]; then
|
|
965
|
+
entry="script"
|
|
966
|
+
[ "${PLOT_APPROVE_ENTRY:-}" = "board" ] && entry="board"
|
|
967
|
+
log_dir="$main_root/.plot/state"
|
|
968
|
+
mkdir -p "$log_dir" 2>/dev/null \
|
|
969
|
+
&& printf '%s\t%s\t%s\t%s\n' "$today" "$slug" "$who" "$entry" >> "$log_dir/in-session-approvals.tsv" 2>/dev/null
|
|
970
|
+
fi
|
|
971
|
+
|
|
821
972
|
echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
|
|
822
973
|
# THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
|
|
823
974
|
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
|