@plot-pm/board 0.12.0 → 0.14.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 +138 -137
- package/package.json +2 -1
- package/plot-approve.sh +77 -27
- package/plot-config.sh +17 -0
- package/plot-deliver.sh +82 -25
- package/plot-dispatch.sh +62 -5
- package/plot-host.sh +700 -85
- package/plot-reap.sh +106 -4
- package/plot-state-receipt.sh +308 -0
- package/plot-worker-state.sh +94 -0
package/plot-reap.sh
CHANGED
|
@@ -100,6 +100,15 @@
|
|
|
100
100
|
# repo that configures one the reaper matched nothing and reported
|
|
101
101
|
# `reapable=0 kept=0` over nine trees.
|
|
102
102
|
#
|
|
103
|
+
# A TREE NEITHER TEST PLACES IS REPORTED `unknown` AND STILL NOT TOUCHED.
|
|
104
|
+
# The recognition test above is unchanged in strictness — this adds no tree to
|
|
105
|
+
# the reapable population and offers no removal. What it removes is the
|
|
106
|
+
# SILENCE: until 2026-09-10 such a tree hit `continue`, so it was not reaped,
|
|
107
|
+
# not kept, not counted and not named, and that is how ten finished desks went
|
|
108
|
+
# unnoticed while the reaper reported three. Only a tree under the configured
|
|
109
|
+
# `Worktree root` qualifies; a hand-made checkout elsewhere stays silent,
|
|
110
|
+
# because a person's tree must never become an instruction to remove it.
|
|
111
|
+
#
|
|
103
112
|
# THE MANIFEST GOES WITH THE WORKTREE. `readAgentRegistry` renders one row per
|
|
104
113
|
# manifest, so a reap that removes only the checkout converts a finished agent
|
|
105
114
|
# into an `unknown` row naming a directory that no longer exists — measured
|
|
@@ -231,6 +240,53 @@ if [ -r "$CONFIG" ]; then
|
|
|
231
240
|
fi
|
|
232
241
|
fi
|
|
233
242
|
|
|
243
|
+
# Where the DESKS live, which is the same key resolved for a different question.
|
|
244
|
+
#
|
|
245
|
+
# `LOG_DIR` and this answer two things: `LOG_DIR` is where a finished branch's
|
|
246
|
+
# log files are swept from, and this is the directory a tree must sit under to
|
|
247
|
+
# be a candidate desk at all. A repository with NO configured root has no
|
|
248
|
+
# `.worktrees/` and its desks are named `plot-wt-*` beside the repo — so this
|
|
249
|
+
# stays EMPTY there rather than defaulting to the parent, because the parent
|
|
250
|
+
# holds every sibling checkout a person ever made and calling those candidate
|
|
251
|
+
# desks is the over-broad reading this slice must not introduce.
|
|
252
|
+
#
|
|
253
|
+
# IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`, and that is the one
|
|
254
|
+
# place in this script where the two differ on purpose. `git rev-parse
|
|
255
|
+
# --show-toplevel` answers *this* worktree, so a reaper run from inside a desk
|
|
256
|
+
# resolves `.worktrees` to a directory beneath that desk — which does not
|
|
257
|
+
# exist, so every tree reads as unplaceable-but-elsewhere and the reading is
|
|
258
|
+
# silently empty. Measured 2026-09-10 from this worktree: `$ROOT/.worktrees`
|
|
259
|
+
# named `.../plot-wt-feature-a-finished-desk-is-a-finding/.worktrees` while
|
|
260
|
+
# every desk sits under `.../plot/.worktrees`. A dispatched agent runs the
|
|
261
|
+
# reaper from exactly there, so the wrong answer would be the usual one.
|
|
262
|
+
#
|
|
263
|
+
# `--git-common-dir` is shared by every worktree of one repository, and its
|
|
264
|
+
# parent is the primary checkout — which is what `plot-dispatch.sh` composes
|
|
265
|
+
# desk paths from when it creates them. Same directory, same key, so a
|
|
266
|
+
# creation and this reading cannot disagree.
|
|
267
|
+
#
|
|
268
|
+
# `LOG_DIR` above keeps `$ROOT` untouched: it is this script's established
|
|
269
|
+
# behaviour with its own callers, and changing where a sweep DELETES from is a
|
|
270
|
+
# blast radius rather than a reading. Named here rather than fixed silently.
|
|
271
|
+
WT_ROOT=""
|
|
272
|
+
if [ -r "$CONFIG" ]; then
|
|
273
|
+
d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
|
|
274
|
+
if [ -n "$d" ]; then
|
|
275
|
+
case "$d" in
|
|
276
|
+
/*) WT_ROOT="$d" ;;
|
|
277
|
+
*)
|
|
278
|
+
# Falls back to `$ROOT` where git cannot answer, which is the same
|
|
279
|
+
# directory on a single-checkout repository and the only one available.
|
|
280
|
+
main_checkout=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)") \
|
|
281
|
+
|| main_checkout="$ROOT"
|
|
282
|
+
[ -d "$main_checkout" ] || main_checkout="$ROOT"
|
|
283
|
+
WT_ROOT="$main_checkout/$d"
|
|
284
|
+
;;
|
|
285
|
+
esac
|
|
286
|
+
WT_ROOT="${WT_ROOT%/}"
|
|
287
|
+
fi
|
|
288
|
+
fi
|
|
289
|
+
|
|
234
290
|
# The files ONE branch's agent run leaves beside its worktree, removed with it.
|
|
235
291
|
#
|
|
236
292
|
# WHICH LOG THIS IS, since the plan says "the dispatcher log" and the estate
|
|
@@ -326,7 +382,7 @@ manifest_for() {
|
|
|
326
382
|
return 1
|
|
327
383
|
}
|
|
328
384
|
|
|
329
|
-
reap=0; kept=0; removed=0; cleared=0; vanished=0
|
|
385
|
+
reap=0; kept=0; removed=0; cleared=0; vanished=0; unplaced=0
|
|
330
386
|
printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
331
387
|
|
|
332
388
|
while IFS=$'\t' read -r wt br prunable; do
|
|
@@ -381,8 +437,54 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
381
437
|
# and whose path does not match goes unrecognised — which fails by
|
|
382
438
|
# REFUSING, the same safe direction the path test failed in, and for one
|
|
383
439
|
# tree instead of all of them.
|
|
384
|
-
|
|
385
|
-
|
|
440
|
+
#
|
|
441
|
+
# THE REFUSAL IS NOW MEASURED RATHER THAN PERFORMED, and that is this
|
|
442
|
+
# slice's whole subject. The two tests below produce READINGS; nothing
|
|
443
|
+
# here decides what the readings mean. A `case` deciding it is where the
|
|
444
|
+
# 2026-08-30 defect lived — one line, in shell, that no test could reach,
|
|
445
|
+
# shadowing `ReapEvidence.isDispatchTree`, declared for the same question.
|
|
446
|
+
#
|
|
447
|
+
# `is_dispatch_tree` is UNCHANGED in strictness. Widening it would move
|
|
448
|
+
# trees into the reaper's population, which is a blast radius, not a
|
|
449
|
+
# report — and turning a person's checkout into a removal instruction is
|
|
450
|
+
# worse than the silence being fixed.
|
|
451
|
+
#
|
|
452
|
+
# `unclassified` is the reading that was missing: a tree sitting under
|
|
453
|
+
# the configured `Worktree root` that neither test placed. It looks like a
|
|
454
|
+
# desk by location and is recognised as one by nothing, so a person is the
|
|
455
|
+
# only thing that can say. Measured 2026-09-10 on this estate:
|
|
456
|
+
# `.worktrees/feature-one-monitor-watches-the-slice`, 5 unpushed commits,
|
|
457
|
+
# a PLOT-BLOCKED marker and no PR, reported by nothing.
|
|
458
|
+
is_dispatch_tree=false
|
|
459
|
+
if [ -f "$wt/.plot-worker.pid" ]; then
|
|
460
|
+
is_dispatch_tree=true
|
|
461
|
+
else
|
|
462
|
+
case "$wt" in *"/plot-wt-"*) is_dispatch_tree=true ;; esac
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
unclassified=false
|
|
466
|
+
if [ "$is_dispatch_tree" = false ] && [ -n "$WT_ROOT" ]; then
|
|
467
|
+
case "$wt" in "$WT_ROOT"/*) unclassified=true ;; esac
|
|
468
|
+
fi
|
|
469
|
+
|
|
470
|
+
# A tree that is NEITHER is silent, and that silence is correct: it is a
|
|
471
|
+
# hand-made checkout outside the population, not a tree that failed a test.
|
|
472
|
+
# Measured here — four `/tmp` baseline and scratchpad checkouts, each of
|
|
473
|
+
# which carries a `.plot/` directory because the repo tracks one, which is
|
|
474
|
+
# why the presence of `.plot/` cannot be the test.
|
|
475
|
+
if [ "$is_dispatch_tree" = false ] && [ "$unclassified" = false ]; then
|
|
476
|
+
continue
|
|
477
|
+
fi
|
|
478
|
+
|
|
479
|
+
# Reported and NOT judged. Every reading below measures something inside a
|
|
480
|
+
# desk, and this is a tree nothing has established is one — so the loop says
|
|
481
|
+
# what it found and moves on, rather than asking the rule a question about a
|
|
482
|
+
# population the rule excludes. It counts, because a refusal that counts is
|
|
483
|
+
# the difference between *nothing to clean* and *nothing was looked at*.
|
|
484
|
+
if [ "$unclassified" = true ]; then
|
|
485
|
+
printf '%-8s %-52s %s\n' "unknown" "$short" \
|
|
486
|
+
"under $(basename "$WT_ROOT")/, no worker pid and no recognised name — needs a person"
|
|
487
|
+
unplaced=$((unplaced+1)); continue
|
|
386
488
|
fi
|
|
387
489
|
|
|
388
490
|
# THE READINGS. Everything from here to the rule call MEASURES; nothing
|
|
@@ -940,5 +1042,5 @@ fi
|
|
|
940
1042
|
# which is why `plot-release-refs.sh` deletes those, plan-scoped, under its own
|
|
941
1043
|
# licence and its own five guards. The asymmetry between the kinds is the whole
|
|
942
1044
|
# safety argument and it stays.
|
|
943
|
-
echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
|
|
1045
|
+
echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished unplaced=$unplaced cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
|
|
944
1046
|
exit 0
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The receipt a lifecycle write leaves behind, and the ONE place it is written
|
|
3
|
+
# and read — sourced, not run, by the scripts that own a `State:` line and by
|
|
4
|
+
# `plot-state-gate.sh`, which refuses every other writer.
|
|
5
|
+
#
|
|
6
|
+
# record_state_receipt <path> <value> # the owning script, after its write
|
|
7
|
+
# receipt_clears <path> <value> # the gate, before it refuses
|
|
8
|
+
#
|
|
9
|
+
# IT IS ALSO RUNNABLE, and that is the named escape:
|
|
10
|
+
#
|
|
11
|
+
# plot-state-receipt.sh --unowned <path> <value> <reason>
|
|
12
|
+
#
|
|
13
|
+
# for the three writes that have NO owning script today. `/plot-approve` step 3b
|
|
14
|
+
# writes the phase by hand under `Review: in-session` and `Review: ballot` —
|
|
15
|
+
# `plot-approve.sh:190` refuses both by name, because a script cannot stand in
|
|
16
|
+
# for a human reviewer or read a ballot. `/plot-release` writes `State:
|
|
17
|
+
# Released`, and `/plot-reject` writes Delivered -> Approved; `setPlanPhase`
|
|
18
|
+
# does not exist, so neither has a script to route to.
|
|
19
|
+
#
|
|
20
|
+
# A GATE WITH NO EXIT IS ONE PEOPLE ROUTE AROUND — `plot-dispatch.sh`'s
|
|
21
|
+
# `--allow-local` in the same tradition, and the reason this slice waited at all:
|
|
22
|
+
# a gate refusing the only available method stops work rather than routing it.
|
|
23
|
+
#
|
|
24
|
+
# THE ESCAPE IS COUNTABLE, WHICH IS THE POINT. The reason is required and it is
|
|
25
|
+
# recorded, so each use is a line naming a routing gap the later slices of
|
|
26
|
+
# `the-master-agent-uses-the-controllers` close: `/plot-reject` becomes a
|
|
27
|
+
# controller command, `/plot-release` asks a controller for its verdict. When
|
|
28
|
+
# those land, their scripts record their own receipts and the escape stops being
|
|
29
|
+
# reached for. Until then it is the difference between a gap that is visible and
|
|
30
|
+
# a gate that is turned off.
|
|
31
|
+
#
|
|
32
|
+
# WHY A RECEIPT RATHER THAN A COMMIT MESSAGE. `plot-state-gate.sh` sees a
|
|
33
|
+
# `git commit`, and every fact reachable from there — the message, the branch,
|
|
34
|
+
# the author — is one an agent types. A receipt is not: it names the file and
|
|
35
|
+
# the value the owning script decided, and the only way to produce a matching
|
|
36
|
+
# one is to have run that script. That is the difference between a gate and a
|
|
37
|
+
# rule, and this whole plan exists because a rule was walked past three times in
|
|
38
|
+
# one afternoon.
|
|
39
|
+
#
|
|
40
|
+
# WHAT IT DOES NOT PROVE. The receipt says *this value was written here by an
|
|
41
|
+
# owner*; it does not say the working tree still holds only that write. An agent
|
|
42
|
+
# that runs `/plot-approve` and then edits the same line again inside the same
|
|
43
|
+
# commit is not caught, and no receipt could catch it — the gate reads the
|
|
44
|
+
# staged value, so a second edit changes the value and the receipt stops
|
|
45
|
+
# matching. Only a re-write to the SAME value would pass, and that write is a
|
|
46
|
+
# no-op.
|
|
47
|
+
#
|
|
48
|
+
# MACHINE-LOCAL AND UNTRACKED. `.plot/state/` is gitignored for the reason
|
|
49
|
+
# `plot-boardctl.sh:83` gives: a receipt is true on the machine that made it,
|
|
50
|
+
# and a receipt travelling in a commit would clear the gate on every other
|
|
51
|
+
# checkout that pulled it.
|
|
52
|
+
#
|
|
53
|
+
# A RECEIPT IS SPENT WHEN IT CLEARS. `receipt_clears` deletes the one it
|
|
54
|
+
# matched, so a single `/plot-approve` licenses a single commit. Leaving it
|
|
55
|
+
# would let one approval clear a `sed` over the same line a week later.
|
|
56
|
+
#
|
|
57
|
+
# IT FAILS TOWARD REFUSING, and that is the opposite of `plot-phase-gate.sh`'s
|
|
58
|
+
# choice, deliberately: an unwritable state directory means no receipt exists,
|
|
59
|
+
# so `receipt_clears` answers false and the gate refuses with the command to
|
|
60
|
+
# run. The blast radius is one commit an operator repeats through the owning
|
|
61
|
+
# script, against a phase gate whose refusal would cost every commit in the
|
|
62
|
+
# repository.
|
|
63
|
+
|
|
64
|
+
# Where receipts live. One file per receipt, named by a hash of the path, so two
|
|
65
|
+
# lifecycle writes in one session never overwrite each other.
|
|
66
|
+
_receipt_dir() {
|
|
67
|
+
local root
|
|
68
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
|
69
|
+
printf '%s\n' "$root/.plot/state/state-receipts"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# The receipt's identity is the repo-relative path. Hashed rather than
|
|
73
|
+
# flattened: a path is not a filename, and `docs/plans/x.md` and `docs-plans/x.md`
|
|
74
|
+
# must not collide.
|
|
75
|
+
_receipt_file() { # $1=repo-relative path
|
|
76
|
+
local dir
|
|
77
|
+
dir="$(_receipt_dir)" || return 1
|
|
78
|
+
printf '%s/%s\n' "$dir" "$(printf '%s' "$1" | git hash-object --stdin 2>/dev/null)"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# $1 = path (absolute or relative), $2 = the value written.
|
|
82
|
+
record_state_receipt() {
|
|
83
|
+
local path="$1" value="$2" root rel file
|
|
84
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
|
|
85
|
+
rel="$(_repo_relative "$path" "$root")" || return 0
|
|
86
|
+
file="$(_receipt_file "$rel")" || return 0
|
|
87
|
+
mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
|
|
88
|
+
printf '%s\t%s\n' "$rel" "$value" > "$file" 2>/dev/null || return 0
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# $1 = repo-relative path, $2 = the staged value. Prints nothing; exit 0 means
|
|
93
|
+
# an owner wrote exactly this, and the receipt is spent.
|
|
94
|
+
receipt_clears() {
|
|
95
|
+
local rel="$1" value="$2" file recorded
|
|
96
|
+
file="$(_receipt_file "$rel")" || return 1
|
|
97
|
+
[ -f "$file" ] || return 1
|
|
98
|
+
recorded="$(cut -f2 <"$file" 2>/dev/null)" || return 1
|
|
99
|
+
[ "$recorded" = "$value" ] || return 1
|
|
100
|
+
rm -f "$file" 2>/dev/null
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# A path as git names it. `git rev-parse` is not used: the file may be a scratch
|
|
105
|
+
# copy that has already been `mv`ed, and this must work on a path alone.
|
|
106
|
+
_repo_relative() { # $1=path $2=root
|
|
107
|
+
local path="$1" root="$2" dir base
|
|
108
|
+
case "$path" in
|
|
109
|
+
/*) ;;
|
|
110
|
+
*) path="$PWD/$path" ;;
|
|
111
|
+
esac
|
|
112
|
+
dir="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)" || return 1
|
|
113
|
+
base="$(basename "$path")"
|
|
114
|
+
root="$(cd "$root" 2>/dev/null && pwd -P)" || return 1
|
|
115
|
+
case "$dir" in
|
|
116
|
+
"$root") printf '%s\n' "$base" ;;
|
|
117
|
+
"$root"/*) printf '%s/%s\n' "${dir#"$root"/}" "$base" ;;
|
|
118
|
+
*) return 1 ;;
|
|
119
|
+
esac
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# --- the ACTION receipt: the controller's, one layer earlier ------------------
|
|
123
|
+
#
|
|
124
|
+
# The same instrument aimed at a different question. A state receipt answers
|
|
125
|
+
# *did an owning script write this value?*; an action receipt answers *did a
|
|
126
|
+
# controller authorise this script running at all?*
|
|
127
|
+
#
|
|
128
|
+
# record_action_receipt <script> <subject> # the endpoint, before it spawns
|
|
129
|
+
# action_receipt_clears <script> # the gate, before it refuses
|
|
130
|
+
# spend_action_receipt <script> # the script, on its own exit 0
|
|
131
|
+
#
|
|
132
|
+
# ONLY THE CONTROLLER CAN LEAVE ONE, because only the controller runs before the
|
|
133
|
+
# script does. That is the whole property: `/api/dispatch` and a hand-typed
|
|
134
|
+
# `bash plot-dispatch.sh <slug>` are byte-identical at the command line, and no
|
|
135
|
+
# grep separates them. A receipt is not something the caller types.
|
|
136
|
+
#
|
|
137
|
+
# IT IS SPENT ON THE ACTION COMPLETING, NOT ON THE GATE CLEARING, and that is a
|
|
138
|
+
# DELIBERATE DIFFERENCE from `receipt_clears` above. `plot-approve.sh` and
|
|
139
|
+
# `plot-deliver.sh` are documented idempotent precisely because their
|
|
140
|
+
# irreversible step is a host write: *"re-running is the repair for any
|
|
141
|
+
# interruption after it"*. A receipt spent at the gate would refuse that repair,
|
|
142
|
+
# turning the documented fix into a second controller call — and the operator
|
|
143
|
+
# most likely to need it is the one whose first run died halfway. So the gate
|
|
144
|
+
# clearing LEAVES the receipt, the script's own successful exit spends it, and a
|
|
145
|
+
# failed run is retried on the same licence.
|
|
146
|
+
#
|
|
147
|
+
# THE SUBJECT IS RECORDED AND NOT MATCHED ON. It is the slug the controller
|
|
148
|
+
# named, kept so an operator reading the file learns which action was licensed.
|
|
149
|
+
# The gate matches on the SCRIPT alone: `/api/dispatch` names a plan slug and
|
|
150
|
+
# `plot-dispatch.sh` fans out to branches, so a gate comparing the two words
|
|
151
|
+
# would refuse the controller's own call. Matching what the caller typed against
|
|
152
|
+
# what the endpoint meant is a second rule, and this file holds none.
|
|
153
|
+
|
|
154
|
+
# Where action receipts live — beside the state receipts, and machine-local for
|
|
155
|
+
# the same reason: one travelling in a commit would clear the gate on every
|
|
156
|
+
# checkout that pulled it.
|
|
157
|
+
_action_receipt_dir() {
|
|
158
|
+
local root
|
|
159
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
|
160
|
+
printf '%s\n' "$root/.plot/state/action-receipts"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# NAMED BY THE ACTION, not by the script. The board writes these too, and
|
|
164
|
+
# `scripts/check-script-names.sh` refuses a `plot-*.sh` literal outside an
|
|
165
|
+
# adapter — a script name in a controller is a boundary crossing no spawn
|
|
166
|
+
# counter can see. So the shared filename is the action word, and mapping a
|
|
167
|
+
# script back to it happens HERE, on the side that reads command lines.
|
|
168
|
+
_action_of() { # $1=script name or action word → the action, or nothing
|
|
169
|
+
case "${1##*/}" in
|
|
170
|
+
plot-dispatch.sh|dispatch) printf 'dispatch\n' ;;
|
|
171
|
+
plot-approve.sh|approve) printf 'approve\n' ;;
|
|
172
|
+
plot-deliver.sh|deliver) printf 'deliver\n' ;;
|
|
173
|
+
esac
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_action_receipt_file() { # $1=script name or action word
|
|
177
|
+
local dir action
|
|
178
|
+
dir="$(_action_receipt_dir)" || return 1
|
|
179
|
+
action="$(_action_of "$1")" || return 1
|
|
180
|
+
[ -n "$action" ] || return 1
|
|
181
|
+
printf '%s/%s\n' "$dir" "$action"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# $1 = the script the controller is about to run, $2 = what it is running it on.
|
|
185
|
+
# Called by the controller endpoint immediately before it spawns.
|
|
186
|
+
record_action_receipt() {
|
|
187
|
+
local action subject="${2:-}" file
|
|
188
|
+
action="$(_action_of "${1:-}")"
|
|
189
|
+
[ -n "$action" ] || return 0
|
|
190
|
+
file="$(_action_receipt_file "$action")" || return 0
|
|
191
|
+
mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
|
|
192
|
+
printf '%s\t%s\t%s\n' "$action" "$subject" "$(date +%FT%T)" > "$file" 2>/dev/null || return 0
|
|
193
|
+
return 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# $1 = the script named on the command line. Exit 0 means a controller
|
|
197
|
+
# authorised it. THE RECEIPT IS LEFT IN PLACE — see the spending rule above.
|
|
198
|
+
action_receipt_clears() {
|
|
199
|
+
local file
|
|
200
|
+
file="$(_action_receipt_file "${1##*/}")" || return 1
|
|
201
|
+
[ -f "$file" ] || return 1
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# $1 = the script that just finished. Called by the script itself on exit 0, so
|
|
206
|
+
# one authorisation licenses one COMPLETED action.
|
|
207
|
+
spend_action_receipt() {
|
|
208
|
+
local file
|
|
209
|
+
file="$(_action_receipt_file "${1##*/}")" || return 0
|
|
210
|
+
rm -f "$file" 2>/dev/null
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# --- the named escape, when this file is RUN rather than sourced -------------
|
|
215
|
+
#
|
|
216
|
+
# Guarded on BASH_SOURCE so sourcing never executes it: the three owning scripts
|
|
217
|
+
# source this file and pass no arguments, and a bare `$1` under `set -u` would
|
|
218
|
+
# abort them at load.
|
|
219
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
220
|
+
set -uo pipefail
|
|
221
|
+
|
|
222
|
+
# THE SECOND ESCAPE, for the layer above: a controller-owned ACTION run
|
|
223
|
+
# without a controller, which `plot-controller-gate.sh` refuses. Same shape
|
|
224
|
+
# and same argument as `--unowned` below — the reason is REQUIRED, and each
|
|
225
|
+
# use appends a line to `.plot/state/unowned-action-writes.tsv`, which is the
|
|
226
|
+
# measurement of how large the routing gap still is. An uncounted escape is an
|
|
227
|
+
# off switch.
|
|
228
|
+
if [ "${1:-}" = "--unowned-action" ]; then
|
|
229
|
+
shift
|
|
230
|
+
action_script="${1:-}"; action_subject="${2:-}"; shift 2 2>/dev/null || true
|
|
231
|
+
action_reason="$*"
|
|
232
|
+
if [ -z "$action_script" ] || [ -z "$action_subject" ] || [ -z "$action_reason" ]; then
|
|
233
|
+
echo "plot-state-receipt: --unowned-action needs an action, a subject and a reason." >&2
|
|
234
|
+
echo " usage: plot-state-receipt.sh --unowned-action <dispatch|approve|deliver> <slug> <reason>" >&2
|
|
235
|
+
echo "" >&2
|
|
236
|
+
echo " The reason is required because each use names a routing gap, and a gap" >&2
|
|
237
|
+
echo " nobody wrote down is one nobody closes. Where the board IS running, the" >&2
|
|
238
|
+
echo " controller is the route and this is not needed." >&2
|
|
239
|
+
exit 2
|
|
240
|
+
fi
|
|
241
|
+
# The action word, or the script name — an operator reaching for this has
|
|
242
|
+
# just read a refusal naming a script, so both spellings are accepted.
|
|
243
|
+
case "$action_script" in
|
|
244
|
+
dispatch|plot-dispatch.sh) action_script="dispatch" ;;
|
|
245
|
+
approve|plot-approve.sh) action_script="approve" ;;
|
|
246
|
+
deliver|plot-deliver.sh) action_script="deliver" ;;
|
|
247
|
+
*)
|
|
248
|
+
echo "plot-state-receipt: '$action_script' is not a controller-owned action." >&2
|
|
249
|
+
echo " The three are: dispatch, approve, deliver." >&2
|
|
250
|
+
exit 2
|
|
251
|
+
;;
|
|
252
|
+
esac
|
|
253
|
+
record_action_receipt "$action_script" "$action_subject"
|
|
254
|
+
log_root="$(git rev-parse --show-toplevel 2>/dev/null)" || log_root=""
|
|
255
|
+
if [ -n "$log_root" ]; then
|
|
256
|
+
mkdir -p "$log_root/.plot/state" 2>/dev/null || true
|
|
257
|
+
printf '%s\t%s\t%s\t%s\n' "$(date +%F)" "$action_script" "$action_subject" "$action_reason" \
|
|
258
|
+
>> "$log_root/.plot/state/unowned-action-writes.tsv" 2>/dev/null || true
|
|
259
|
+
fi
|
|
260
|
+
echo "plot-state-receipt: recorded an UNOWNED action — $action_script $action_subject"
|
|
261
|
+
echo " reason: $action_reason"
|
|
262
|
+
echo " This clears plot-controller-gate.sh for one run of that script. The controller"
|
|
263
|
+
echo " is still the route: this exists for a machine running no board."
|
|
264
|
+
exit 0
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
if [ "${1:-}" != "--unowned" ]; then
|
|
268
|
+
echo "usage: plot-state-receipt.sh --unowned <path> <value> <reason>" >&2
|
|
269
|
+
echo " plot-state-receipt.sh --unowned-action <dispatch|approve|deliver> <slug> <reason>" >&2
|
|
270
|
+
echo "" >&2
|
|
271
|
+
echo "The named escape for a lifecycle write with no owning script:" >&2
|
|
272
|
+
echo " /plot-approve under 'Review: in-session' or 'Review: ballot'" >&2
|
|
273
|
+
echo " /plot-release writing 'State: Released'" >&2
|
|
274
|
+
echo " /plot-reject reverting Delivered -> Approved" >&2
|
|
275
|
+
echo "" >&2
|
|
276
|
+
echo "--unowned-action is the other layer: a controller-owned action run with no" >&2
|
|
277
|
+
echo "controller, for a machine running no board." >&2
|
|
278
|
+
echo "" >&2
|
|
279
|
+
echo "Every other State: write has a command that owns it. Use that command." >&2
|
|
280
|
+
exit 2
|
|
281
|
+
fi
|
|
282
|
+
shift
|
|
283
|
+
unowned_path="${1:-}"; unowned_value="${2:-}"; shift 2 2>/dev/null || true
|
|
284
|
+
unowned_reason="$*"
|
|
285
|
+
if [ -z "$unowned_path" ] || [ -z "$unowned_value" ] || [ -z "$unowned_reason" ]; then
|
|
286
|
+
echo "plot-state-receipt: --unowned needs a path, a value and a reason." >&2
|
|
287
|
+
echo " The reason is required because each use names a routing gap, and a gap" >&2
|
|
288
|
+
echo " nobody wrote down is one nobody closes." >&2
|
|
289
|
+
exit 2
|
|
290
|
+
fi
|
|
291
|
+
[ -f "$unowned_path" ] || {
|
|
292
|
+
echo "plot-state-receipt: $unowned_path is not a file — the receipt names a write that happened." >&2
|
|
293
|
+
exit 1
|
|
294
|
+
}
|
|
295
|
+
record_state_receipt "$unowned_path" "$unowned_value"
|
|
296
|
+
# The log is the countable half. One line per escape, appended, never rotated
|
|
297
|
+
# here: it is small by construction and the later slices are what shorten it.
|
|
298
|
+
log_root="$(git rev-parse --show-toplevel 2>/dev/null)" || log_root=""
|
|
299
|
+
if [ -n "$log_root" ]; then
|
|
300
|
+
mkdir -p "$log_root/.plot/state" 2>/dev/null || true
|
|
301
|
+
printf '%s\t%s\t%s\t%s\n' "$(date +%F)" "$unowned_path" "$unowned_value" "$unowned_reason" \
|
|
302
|
+
>> "$log_root/.plot/state/unowned-state-writes.tsv" 2>/dev/null || true
|
|
303
|
+
fi
|
|
304
|
+
echo "plot-state-receipt: recorded an UNOWNED write — $unowned_path -> $unowned_value"
|
|
305
|
+
echo " reason: $unowned_reason"
|
|
306
|
+
echo " This clears plot-state-gate.sh for one commit. It is a routing gap, not a workflow:"
|
|
307
|
+
echo " the later slices of the-master-agent-uses-the-controllers give these writes a command."
|
|
308
|
+
fi
|
package/plot-worker-state.sh
CHANGED
|
@@ -754,3 +754,97 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
|
|
|
754
754
|
# mistake in the other direction.
|
|
755
755
|
printf 'ended\t%s\t' "$pid"
|
|
756
756
|
}
|
|
757
|
+
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
# THE SAME READINGS, HANDED OUT RATHER THAN DECIDED
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
#
|
|
762
|
+
# `plot_worker_state` above gathers six facts and turns them into a word. So
|
|
763
|
+
# does `rules/agent-state.ts`, and `docs/shell-and-domain.md` says why both
|
|
764
|
+
# exist: this function is sourced inside per-branch loops and by the agent's own
|
|
765
|
+
# loop, where a `node` hop is 39 ms every caller pays on every pass, while the
|
|
766
|
+
# board and the supervisor are already in node and pay nothing.
|
|
767
|
+
#
|
|
768
|
+
# WHAT THE PAIR NEEDS IS THE READINGS, NOT THE WORD. A caller handed `finished`
|
|
769
|
+
# can only compare two strings; a caller handed the six facts can ask the domain
|
|
770
|
+
# rule and compare its answer to this file's. That is what the corpus test does,
|
|
771
|
+
# and it is why this function exists at all.
|
|
772
|
+
#
|
|
773
|
+
# ONE GATHERING, TWO CONSUMERS. Every fact below is read the way
|
|
774
|
+
# `plot_worker_state` reads it — the manifest first and the worktree file as the
|
|
775
|
+
# fallback, `plot_pid_is_current` for staleness, the same `PLOT-BLOCKED*` glob
|
|
776
|
+
# and the same dirty filter. A second gathering that drifted would make the
|
|
777
|
+
# corpus test compare this file against itself and pass while production broke.
|
|
778
|
+
#
|
|
779
|
+
# Prints one TAB-separated line, six fields:
|
|
780
|
+
#
|
|
781
|
+
# worktree_here pid_recorded liveness exit blocked dirty unpushed
|
|
782
|
+
#
|
|
783
|
+
# `liveness` is `live`, `stale` or `dead`; `exit` is the code as read, empty for
|
|
784
|
+
# an unreadable record and the literal `-` for an absent one, because an empty
|
|
785
|
+
# field cannot say which of the two it is and the rule answers them alike only
|
|
786
|
+
# because it was told they differ. The PR fact is NOT here: it comes from the
|
|
787
|
+
# caller, exactly as it does for `plot_worker_state`.
|
|
788
|
+
plot_worker_readings() { # $1=worktree → "here\tpid\tliveness\texit\tblocked\tdirty\tunpushed"
|
|
789
|
+
local wt="$1" pid="" started_at="" manifest_data="" manifest=""
|
|
790
|
+
local here=1 pid_recorded=0 liveness=dead exit_field='-' blocked=0 dirty=0 ahead=""
|
|
791
|
+
|
|
792
|
+
# NO WORKTREE IS ANSWERED FIRST, and it is a question about the worktree LIST
|
|
793
|
+
# rather than about anything inside one — the same split `worker_of` makes in
|
|
794
|
+
# `plot-fleet-scan.sh`, where `elsewhere` is decided before this file is
|
|
795
|
+
# reached. A caller iterating worktrees it found never sees this arm.
|
|
796
|
+
if [ -z "$wt" ] || [ ! -d "$wt" ]; then
|
|
797
|
+
printf '0\t0\tdead\t-\t0\t0\t'
|
|
798
|
+
return
|
|
799
|
+
fi
|
|
800
|
+
|
|
801
|
+
# THE MANIFEST IS PRIMARY, as above: it carries `pid` and `startedAt`
|
|
802
|
+
# together, and `startedAt` is what tells a reused pid from the real worker.
|
|
803
|
+
if manifest=$(plot_manifest_for_worktree "$wt" 2>/dev/null) && [ -n "$manifest" ]; then
|
|
804
|
+
if manifest_data=$(plot_read_manifest_pid "$manifest") && [ -n "$manifest_data" ]; then
|
|
805
|
+
pid=$(printf '%s' "$manifest_data" | cut -f1)
|
|
806
|
+
started_at=$(printf '%s' "$manifest_data" | cut -f2)
|
|
807
|
+
fi
|
|
808
|
+
fi
|
|
809
|
+
if [ -z "$pid" ] && [ -f "$wt/.plot-worker.pid" ]; then
|
|
810
|
+
pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null | tr -d ' \n')
|
|
811
|
+
fi
|
|
812
|
+
|
|
813
|
+
# A pid of 0 and any non-numeric junk are NOT pids. `kill -0 0` signals the
|
|
814
|
+
# whole process group and succeeds, so a zero read as live reports `running`
|
|
815
|
+
# forever — rejected here exactly as `plot_worker_state` rejects it.
|
|
816
|
+
case "$pid" in
|
|
817
|
+
''|0|*[!0-9]*) pid_recorded=0 ;;
|
|
818
|
+
*) pid_recorded=1 ;;
|
|
819
|
+
esac
|
|
820
|
+
|
|
821
|
+
if [ "$pid_recorded" = 1 ]; then
|
|
822
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
823
|
+
# A recorded start time closes the pid-reuse window. Without one the old
|
|
824
|
+
# behaviour applies and `kill -0` alone decides, which keeps an
|
|
825
|
+
# uncheckable pid honest rather than pessimistic.
|
|
826
|
+
if [ -n "$started_at" ] && ! plot_pid_is_current "$pid" "$started_at"; then
|
|
827
|
+
liveness=stale
|
|
828
|
+
else
|
|
829
|
+
liveness=live
|
|
830
|
+
fi
|
|
831
|
+
fi
|
|
832
|
+
fi
|
|
833
|
+
|
|
834
|
+
# THE EXIT RECORD, distinguishing absent from unreadable. `plot_worker_state`
|
|
835
|
+
# reaches `ended` for both, but by different routes, and a reading that
|
|
836
|
+
# collapsed them would hide which one a desk is in from anybody comparing.
|
|
837
|
+
if [ -f "$wt/.plot-worker.exit" ]; then
|
|
838
|
+
exit_field=$(cat "$wt/.plot-worker.exit" 2>/dev/null | tr -d ' \n')
|
|
839
|
+
fi
|
|
840
|
+
|
|
841
|
+
plot_worker_blocked "$wt" && blocked=1 || blocked=0
|
|
842
|
+
[ -n "$(plot_worker_dirty "$wt")" ] && dirty=1 || dirty=0
|
|
843
|
+
# UNPUSHED IS A REF QUESTION asked THROUGH the worktree, and an unreadable
|
|
844
|
+
# count stays EMPTY — `null` is not `false`, and a branch with no upstream
|
|
845
|
+
# cannot be asked at all.
|
|
846
|
+
ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || ahead=""
|
|
847
|
+
|
|
848
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
|
|
849
|
+
"$here" "$pid_recorded" "$liveness" "$exit_field" "$blocked" "$dirty" "$ahead"
|
|
850
|
+
}
|