@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"plot-reap.sh",
|
|
39
39
|
"plot-release-refs.sh",
|
|
40
40
|
"plot-resolve-artifact.sh",
|
|
41
|
+
"plot-state-receipt.sh",
|
|
41
42
|
"plot-transcript-quiet.sh",
|
|
42
43
|
"plot-worker-monitor.sh",
|
|
43
44
|
"plot-worker-state.sh"
|
package/plot-approve.sh
CHANGED
|
@@ -99,6 +99,12 @@ set -uo pipefail
|
|
|
99
99
|
|
|
100
100
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
101
101
|
|
|
102
|
+
# The receipt this script leaves for plot-state-gate.sh, which refuses every
|
|
103
|
+
# other writer of a `State:` line. Sourced rather than run: the gate and the
|
|
104
|
+
# three owning scripts must agree on where a receipt lives, and one file is how.
|
|
105
|
+
# shellcheck source=plot-state-receipt.sh
|
|
106
|
+
. "$script_dir/plot-state-receipt.sh"
|
|
107
|
+
|
|
102
108
|
dry_run=0
|
|
103
109
|
who_override=""
|
|
104
110
|
slug=""
|
|
@@ -116,7 +122,7 @@ done
|
|
|
116
122
|
die() { echo "plot-approve: $*" >&2; exit 1; }
|
|
117
123
|
|
|
118
124
|
[ -n "$slug" ] || die "need a plan slug (usage: plot-approve.sh [--dry-run] <slug>)"
|
|
119
|
-
git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
|
|
125
|
+
git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run this from inside the checkout, or 'git init' one here"
|
|
120
126
|
|
|
121
127
|
cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
|
|
122
128
|
|
|
@@ -135,10 +141,14 @@ plan_file=""
|
|
|
135
141
|
for cand in "$ACTIVE_DIR$slug.md" "$PLAN_DIR"*"$slug".md; do
|
|
136
142
|
[ -e "$cand" ] && { plan_file="$cand"; break; }
|
|
137
143
|
done
|
|
138
|
-
[ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $ACTIVE_DIR and $PLAN_DIR
|
|
144
|
+
[ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $ACTIVE_DIR and $PLAN_DIR.
|
|
145
|
+
Check the slug: ls $PLAN_DIR | grep -i '$slug'
|
|
146
|
+
Or create the plan first: /plot-idea"
|
|
139
147
|
|
|
140
148
|
meta=$(bash "$script_dir/plot-plan-meta.sh" "$plan_file" 2>/dev/null) || meta=""
|
|
141
|
-
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing
|
|
149
|
+
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing.
|
|
150
|
+
See what the parser reads: $script_dir/plot-plan-meta.sh $plan_file
|
|
151
|
+
A plan needs a '## Status' section with a 'State:' field."
|
|
142
152
|
|
|
143
153
|
jfield() { printf '%s' "$meta" | jq -r "$1" 2>/dev/null; }
|
|
144
154
|
|
|
@@ -163,11 +173,14 @@ plan_branches=$(jfield '.branches[]?')
|
|
|
163
173
|
case "$phase" in
|
|
164
174
|
draft|design|approved) ;;
|
|
165
175
|
delivered|released)
|
|
166
|
-
die "plan '$slug' is already $phase — nothing to approve.
|
|
176
|
+
die "plan '$slug' is already $phase — nothing to approve.
|
|
177
|
+
Nothing to do here. To take the work further: /plot-release" ;;
|
|
167
178
|
NONE|"")
|
|
168
|
-
die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing.
|
|
179
|
+
die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing.
|
|
180
|
+
Its '## Status' section needs a line reading '- **State:** Draft'." ;;
|
|
169
181
|
*)
|
|
170
|
-
die "plan '$slug' is in phase '$phase' — only a Draft or Design plan can be approved.
|
|
182
|
+
die "plan '$slug' is in phase '$phase' — only a Draft or Design plan can be approved.
|
|
183
|
+
If that phase is wrong, correct the 'State:' line in $plan_file and push it." ;;
|
|
171
184
|
esac
|
|
172
185
|
|
|
173
186
|
# --- refusal 2: the review channel ------------------------------------------
|
|
@@ -225,9 +238,12 @@ case "$pr_state" in
|
|
|
225
238
|
MERGED) ;;
|
|
226
239
|
OPEN) ;;
|
|
227
240
|
CLOSED)
|
|
228
|
-
die "the plan PR for '$slug' (#$pr_number) is closed.
|
|
241
|
+
die "the plan PR for '$slug' (#$pr_number) is closed.
|
|
242
|
+
Reopen it on the host, or push '$pr_branch' again and open a new one." ;;
|
|
229
243
|
NONE|*)
|
|
230
|
-
die "no PR found for branch '$pr_branch'.
|
|
244
|
+
die "no PR found for branch '$pr_branch'.
|
|
245
|
+
Push the branch: git push -u origin $pr_branch
|
|
246
|
+
Then open its PR — or run /plot-idea, which does both." ;;
|
|
231
247
|
esac
|
|
232
248
|
|
|
233
249
|
echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
|
|
@@ -336,7 +352,8 @@ real_plan_path() { # $1 = plan file as found
|
|
|
336
352
|
}
|
|
337
353
|
|
|
338
354
|
rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
339
|
-
[ -n "$rel" ] || die "$plan_file is outside the repository root
|
|
355
|
+
[ -n "$rel" ] || die "$plan_file is outside the repository root ($repo_root).
|
|
356
|
+
Move the plan under $PLAN_DIR inside this checkout and re-run."
|
|
340
357
|
|
|
341
358
|
# Flip `**State:** Draft` OR `**State:** Design` → `Approved` in the `## Status`
|
|
342
359
|
# section only. Both are the pre-Approved states this script advances from.
|
|
@@ -527,6 +544,10 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
|
|
|
527
544
|
fi
|
|
528
545
|
|
|
529
546
|
phase_report=$([ "$flipped" = 1 ] && echo flipped || echo already)
|
|
547
|
+
# The receipt plot-state-gate.sh clears on. Recorded after the `mv`, so it
|
|
548
|
+
# names a value the file actually carries — a receipt written before a failed
|
|
549
|
+
# write would license a commit of the state that was refused.
|
|
550
|
+
record_state_receipt "$f" "Approved"
|
|
530
551
|
return 0
|
|
531
552
|
}
|
|
532
553
|
|
|
@@ -558,13 +579,20 @@ clear_holds() { # $1=worktree root; reads $plan_branches
|
|
|
558
579
|
}
|
|
559
580
|
|
|
560
581
|
# Update the sprint item annotation this plan appears in:
|
|
561
|
-
# - [ ] [slug] description <!-- pr: #N,
|
|
562
|
-
# /plot-sprint READS these (`pr`, `
|
|
563
|
-
#
|
|
564
|
-
#
|
|
582
|
+
# - [ ] [slug] description <!-- pr: #N, branch: feature/slug -->
|
|
583
|
+
# /plot-sprint READS these (`pr`, `branch`) and /plot-approve writes them, so an
|
|
584
|
+
# approval that skips this makes `/plot-sprint status` wrong rather than merely
|
|
585
|
+
# incomplete.
|
|
586
|
+
#
|
|
587
|
+
# `status:` IS GONE, since 2026-09-08. `a-withdrawn-item-is-not-open` measured
|
|
588
|
+
# it dead in both directions — 67 lines carried one, no reader acted on the
|
|
589
|
+
# value, and `plot-sprint-release.sh` read the field nowhere. The plan file
|
|
590
|
+
# carries `State:` and a dated `Approved:` record; a cache nobody refreshes and
|
|
591
|
+
# nobody reads is a second answer waiting to contradict the first. `pr` and
|
|
592
|
+
# `branch` stay: they name things no plan field holds.
|
|
565
593
|
#
|
|
566
594
|
# A plan in NO sprint is a no-op, never an error — that is the common case.
|
|
567
|
-
# Already-done test: the annotation already carries
|
|
595
|
+
# Already-done test: the annotation already carries this PR and branch.
|
|
568
596
|
update_sprint_annotation() { # $1=worktree root → prints none|updated|already|missing
|
|
569
597
|
local root="$1" f found=""
|
|
570
598
|
[ -n "$sprint" ] || { printf 'none'; return 0; }
|
|
@@ -589,18 +617,27 @@ update_sprint_annotation() { # $1=worktree root → prints none|updated|already|
|
|
|
589
617
|
{
|
|
590
618
|
line = $0
|
|
591
619
|
if (index(line, "<!--") == 0) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
620
|
+
print line " <!-- pr: #" pr (br != "" ? ", branch: " br : "") " -->"
|
|
621
|
+
next
|
|
622
|
+
}
|
|
623
|
+
# THE CLOSING MARKER IS SPLIT OFF BEFORE ANY FIELD IS TOUCHED.
|
|
624
|
+
# A value pattern that has to avoid `-->` cannot be written safely:
|
|
625
|
+
# `[^,>]*[^,> \t]` excluded the `>` and still swallowed the `--`, turning
|
|
626
|
+
# ` -->` into a bare `>` and corrupting the comment — measured 2026-09-08,
|
|
627
|
+
# `approve: updates the sprint annotation the sprint view reads` failed on
|
|
628
|
+
# exactly that. Excluding `-` as well is worse: it rewrites `bug/a-b` as
|
|
629
|
+
# `feature/new-b`, and hyphens are in most branch names here.
|
|
630
|
+
# With the marker held aside, every field ends at a comma or at
|
|
631
|
+
# end-of-string, and none can reach it.
|
|
632
|
+
tail = ""
|
|
633
|
+
if (match(line, /[ \t]*-->[ \t]*$/)) { tail = substr(line, RSTART); line = substr(line, 1, RSTART - 1) }
|
|
634
|
+
if (line ~ /pr:[ \t]*#?[0-9a-z]+/) sub(/pr:[ \t]*#?[0-9a-z]+/, "pr: #" pr, line)
|
|
635
|
+
else sub(/<!--/, "<!-- pr: #" pr ",", line)
|
|
636
|
+
if (br != "") {
|
|
637
|
+
if (line ~ /branch:[ \t]*[^,]/) sub(/branch:[ \t]*[^,]*/, "branch: " br, line)
|
|
638
|
+
else line = line ", branch: " br
|
|
602
639
|
}
|
|
603
|
-
print line
|
|
640
|
+
print line (tail != "" ? tail : " -->")
|
|
604
641
|
}
|
|
605
642
|
' "$found")
|
|
606
643
|
if [ "$before" = "$after" ]; then printf 'already'; return 0; fi
|
|
@@ -659,7 +696,9 @@ if [ "$same_branch" = 1 ]; then
|
|
|
659
696
|
push_report="nothing-to-commit"
|
|
660
697
|
echo "step: nothing to commit — the approval was already recorded"
|
|
661
698
|
else
|
|
662
|
-
git -C "$repo_root" commit -q -m "plot: approve $slug" || die "could not commit the approval
|
|
699
|
+
git -C "$repo_root" commit -q -m "plot: approve $slug" || die "could not commit the approval.
|
|
700
|
+
The PR is already merged; the local record is what is missing. See what git
|
|
701
|
+
refused: git -C $repo_root status. Then re-run this — it is idempotent."
|
|
663
702
|
push_report="local"
|
|
664
703
|
echo "step: recorded on $(git -C "$repo_root" branch --show-current) — push it with the implementation"
|
|
665
704
|
fi
|
|
@@ -675,7 +714,10 @@ else
|
|
|
675
714
|
# -B: a leftover branch from an earlier failed run must not block this one.
|
|
676
715
|
# It is disposable by construction — created here, pushed, deleted.
|
|
677
716
|
git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
|
|
678
|
-
|| die "could not prepare a booking worktree at $tmpwt
|
|
717
|
+
|| die "could not prepare a booking worktree at $tmpwt.
|
|
718
|
+
Most often origin/$MAIN is not fetched, or '$bookbr' is checked out in
|
|
719
|
+
another worktree. Check both: git fetch origin $MAIN && git worktree list
|
|
720
|
+
Nothing has been written locally; the plan is untouched."
|
|
679
721
|
|
|
680
722
|
cleanup() {
|
|
681
723
|
git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
|
|
@@ -744,4 +786,12 @@ else
|
|
|
744
786
|
fi
|
|
745
787
|
|
|
746
788
|
echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
|
|
789
|
+
# THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
|
|
790
|
+
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
|
|
791
|
+
# interrupted run can be repeated on the same licence: this script documents
|
|
792
|
+
# re-running as the repair for any interruption after its irreversible step,
|
|
793
|
+
# and a receipt spent at the gate would refuse that repair in the case it is
|
|
794
|
+
# most needed. One authorisation, one completed action.
|
|
795
|
+
spend_action_receipt "plot-approve.sh"
|
|
796
|
+
|
|
747
797
|
exit 0
|
package/plot-config.sh
CHANGED
|
@@ -95,6 +95,23 @@
|
|
|
95
95
|
# Hosts plans yes | no (no = refuse plan files)
|
|
96
96
|
# Tracker plot | jira | github-issues | linear (+ URL)
|
|
97
97
|
# (plot = plans in this repo ARE the tracker; absent = same)
|
|
98
|
+
# Ticket prefixes the tracker project keys this repository's work lives
|
|
99
|
+
# in, comma-separated (`PROJ-A, PROJ-B`). NOT
|
|
100
|
+
# `Branch prefixes`, which sits next to it and holds
|
|
101
|
+
# `idea/`, `feature/`, `bug/` — that key is structural and
|
|
102
|
+
# names git branches; this one names tracker projects.
|
|
103
|
+
# Read by plot-host.sh's `issue-list` to scope the inbox:
|
|
104
|
+
# the default JQL scopes by person and state, so on a
|
|
105
|
+
# shared instance it is instance-wide and returns other
|
|
106
|
+
# customers' tickets. A LIST, because a repository mapping
|
|
107
|
+
# to several projects is the normal case; adoption seeds
|
|
108
|
+
# ONE prefix and a person adds the rest. Absent = today's
|
|
109
|
+
# unscoped query, byte for byte — a default that filtered
|
|
110
|
+
# on an undeclared key would empty every existing board's
|
|
111
|
+
# inbox on upgrade. `PLOT_JIRA_JQL` overrides both.
|
|
112
|
+
# NEVER WRITTEN EMPTY: an empty list reads as *this
|
|
113
|
+
# repository has no projects* and changes nothing about
|
|
114
|
+
# the query, so adoption omits the key instead.
|
|
98
115
|
# Git host github | bitbucket (resolves gh vs bb)
|
|
99
116
|
# CI jenkins | github-actions | none — which CI system this
|
|
100
117
|
# project uses. Recorded by /plot-board-setup; not yet
|
package/plot-deliver.sh
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
#
|
|
36
36
|
# Each step asks THE SOURCE IT WOULD HAVE WRITTEN whether it is already done:
|
|
37
37
|
# the plan file for the phase and the record, the index directories for the
|
|
38
|
-
# symlink, the sprint file for the
|
|
38
|
+
# symlink, the sprint file for the tick. Never a progress file of its own.
|
|
39
39
|
#
|
|
40
40
|
# WHAT IT REFUSES, and why refusing beats guessing:
|
|
41
41
|
# - phase is not `approved` — nothing to deliver. (Already-Delivered is NOT a
|
|
@@ -49,6 +49,12 @@ set -uo pipefail
|
|
|
49
49
|
|
|
50
50
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
51
51
|
|
|
52
|
+
# The receipt this script leaves for plot-state-gate.sh, which refuses every
|
|
53
|
+
# other writer of a `State:` line. Sourced rather than run: the gate and the
|
|
54
|
+
# three owning scripts must agree on where a receipt lives, and one file is how.
|
|
55
|
+
# shellcheck source=plot-state-receipt.sh
|
|
56
|
+
. "$script_dir/plot-state-receipt.sh"
|
|
57
|
+
|
|
52
58
|
dry_run=0
|
|
53
59
|
who_override=""
|
|
54
60
|
slug=""
|
|
@@ -66,7 +72,7 @@ done
|
|
|
66
72
|
die() { echo "plot-deliver: $*" >&2; exit 1; }
|
|
67
73
|
|
|
68
74
|
[ -n "$slug" ] || die "need a plan slug (usage: plot-deliver.sh [--dry-run] <slug>)"
|
|
69
|
-
git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
|
|
75
|
+
git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run this from inside the checkout, or 'git init' one here"
|
|
70
76
|
|
|
71
77
|
cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
|
|
72
78
|
|
|
@@ -86,10 +92,13 @@ plan_file=""
|
|
|
86
92
|
for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
|
|
87
93
|
[ -e "$cand" ] && { plan_file="$cand"; break; }
|
|
88
94
|
done
|
|
89
|
-
[ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $PLAN_DIR, $ACTIVE_DIR, $DELIVERED_DIR
|
|
95
|
+
[ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $PLAN_DIR, $ACTIVE_DIR, $DELIVERED_DIR.
|
|
96
|
+
Check the slug: ls $PLAN_DIR | grep -i '$slug'"
|
|
90
97
|
|
|
91
98
|
meta=$(bash "$script_dir/plot-plan-meta.sh" "$plan_file" 2>/dev/null) || meta=""
|
|
92
|
-
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing
|
|
99
|
+
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing.
|
|
100
|
+
See what the parser reads: $script_dir/plot-plan-meta.sh $plan_file
|
|
101
|
+
A plan needs a '## Status' section with a 'State:' field."
|
|
93
102
|
|
|
94
103
|
jfield() { printf '%s' "$meta" | jq -r "$1" 2>/dev/null; }
|
|
95
104
|
|
|
@@ -106,13 +115,16 @@ delivered_raw=$(jfield '.delivered_raw')
|
|
|
106
115
|
case "$phase" in
|
|
107
116
|
approved|delivered) ;;
|
|
108
117
|
released)
|
|
109
|
-
die "plan '$slug' is already released — nothing to deliver.
|
|
118
|
+
die "plan '$slug' is already released — nothing to deliver.
|
|
119
|
+
Nothing to do here; the work shipped." ;;
|
|
110
120
|
draft|design)
|
|
111
|
-
die "plan '$slug' is still '$phase' — approve it first
|
|
121
|
+
die "plan '$slug' is still '$phase' — approve it first: /plot-approve $slug" ;;
|
|
112
122
|
NONE|"")
|
|
113
|
-
die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing.
|
|
123
|
+
die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing.
|
|
124
|
+
Its '## Status' section needs a line reading '- **State:** Approved'." ;;
|
|
114
125
|
*)
|
|
115
|
-
die "plan '$slug' is in phase '$phase' — only an Approved plan can be delivered.
|
|
126
|
+
die "plan '$slug' is in phase '$phase' — only an Approved plan can be delivered.
|
|
127
|
+
If that phase is wrong, correct the 'State:' line in $plan_file and push it." ;;
|
|
116
128
|
esac
|
|
117
129
|
|
|
118
130
|
# ---------------------------------------------------------------------------
|
|
@@ -157,7 +169,8 @@ fi
|
|
|
157
169
|
|
|
158
170
|
verdict=$(PLOT_REPO_ROOT="$repo_root" PLOT_SCRIPTS_DIR="$script_dir" \
|
|
159
171
|
node "$ask_mjs" deliverable "$slug" "$plan_file" 2>/dev/null) || verdict=""
|
|
160
|
-
[ -n "$verdict" ] || die "cannot determine deliverability of '$slug' — refusing rather than guessing.
|
|
172
|
+
[ -n "$verdict" ] || die "cannot determine deliverability of '$slug' — refusing rather than guessing.
|
|
173
|
+
See what it said: PLOT_REPO_ROOT=$repo_root PLOT_SCRIPTS_DIR=$script_dir node $ask_mjs deliverable $slug $plan_file"
|
|
161
174
|
|
|
162
175
|
vfield() { printf '%s' "$verdict" | jq -r "$1" 2>/dev/null; }
|
|
163
176
|
|
|
@@ -165,6 +178,27 @@ if [ "$(vfield '.deliverable')" != "true" ]; then
|
|
|
165
178
|
die "$(vfield '.refusal')"
|
|
166
179
|
fi
|
|
167
180
|
|
|
181
|
+
# THE FINDING — a slice whose merged PR carried no implementation.
|
|
182
|
+
#
|
|
183
|
+
# IT REPORTS AND DOES NOT REFUSE, which is the harder call and the right one.
|
|
184
|
+
# Measured over the last 60 merged PRs on 2026-09-08, seven carried no work and
|
|
185
|
+
# only TWO were this defect: three were claim PRs whose slice finished under a
|
|
186
|
+
# DIFFERENT PR, and a gate refusing on those would have blocked a delivery whose
|
|
187
|
+
# work was complete — right about the PR and wrong about the plan.
|
|
188
|
+
#
|
|
189
|
+
# So it is worded as a QUESTION and names the next move, because a reader told
|
|
190
|
+
# only "this carried nothing" still has the decision to make.
|
|
191
|
+
empty_slices=$(printf '%s' "$verdict" | jq -r '.emptySlices // [] | .[]' 2>/dev/null)
|
|
192
|
+
if [ -n "$empty_slices" ]; then
|
|
193
|
+
echo "note: this plan's merged PR carried no implementation on:"
|
|
194
|
+
while IFS= read -r b; do
|
|
195
|
+
[ -n "$b" ] || continue
|
|
196
|
+
echo " - $b"
|
|
197
|
+
done <<< "$empty_slices"
|
|
198
|
+
echo " check whether its work landed under another PR, mark it deferred"
|
|
199
|
+
echo " (<!-- deferred: <reason> -->), or re-open it. Delivery continues."
|
|
200
|
+
fi
|
|
201
|
+
|
|
168
202
|
merged_count=$(vfield '.merged')
|
|
169
203
|
deferred_count=$(vfield '.deferred')
|
|
170
204
|
# Empty rather than `0`, so the suffix below stays absent where the old block
|
|
@@ -187,7 +221,7 @@ today=$(date +%Y-%m-%d)
|
|
|
187
221
|
if [ "$dry_run" = 1 ]; then
|
|
188
222
|
echo "step: would flip Phase → Delivered and fill Delivered: $today"
|
|
189
223
|
echo "step: would move active/ → delivered/ symlink"
|
|
190
|
-
echo "step: would
|
|
224
|
+
echo "step: would tick the sprint item${sprint:+ (sprint: $sprint)}"
|
|
191
225
|
echo "summary: phase=would record=would index=would sprint=would push=would"
|
|
192
226
|
exit 0
|
|
193
227
|
fi
|
|
@@ -221,7 +255,8 @@ real_plan_path() { # $1 = plan file as found
|
|
|
221
255
|
}
|
|
222
256
|
|
|
223
257
|
rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
224
|
-
[ -n "$rel" ] || die "$plan_file is outside the repository root
|
|
258
|
+
[ -n "$rel" ] || die "$plan_file is outside the repository root ($repo_root).
|
|
259
|
+
Move the plan under $PLAN_DIR inside this checkout and re-run."
|
|
225
260
|
|
|
226
261
|
# The filename, for symlink creation.
|
|
227
262
|
plan_basename=$(basename "$rel")
|
|
@@ -409,11 +444,27 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
|
|
|
409
444
|
fi
|
|
410
445
|
|
|
411
446
|
phase_report=$([ "$flipped" = 1 ] && echo flipped || echo already)
|
|
447
|
+
# The receipt plot-state-gate.sh clears on. Recorded after the `mv`, so it
|
|
448
|
+
# names a value the file actually carries — a receipt written before a failed
|
|
449
|
+
# write would license a commit of the state that was refused.
|
|
450
|
+
record_state_receipt "$f" "Delivered"
|
|
412
451
|
return 0
|
|
413
452
|
}
|
|
414
453
|
|
|
415
|
-
#
|
|
416
|
-
|
|
454
|
+
# Tick this plan's sprint item.
|
|
455
|
+
#
|
|
456
|
+
# THE TICK ONLY, since 2026-09-08. This also wrote a `<!-- status: delivered -->`
|
|
457
|
+
# annotation, and `a-withdrawn-item-is-not-open` measured that record dead in
|
|
458
|
+
# both directions: 67 lines carried one, none carried a value any reader acted
|
|
459
|
+
# on, and `plot-sprint-release.sh` read the field nowhere. The plan file carries
|
|
460
|
+
# `State:` and a dated `Delivered:` record, and the estate-outranks-the-checkbox
|
|
461
|
+
# rule points at those — a cache nobody refreshes and nobody reads is a second
|
|
462
|
+
# answer waiting to contradict the first.
|
|
463
|
+
#
|
|
464
|
+
# The box is NOT the same record. `/plot-sprint close` step 2a exists to tick
|
|
465
|
+
# boxes the estate says are done, and ticking here is what keeps that step's
|
|
466
|
+
# work to the items it genuinely cannot resolve.
|
|
467
|
+
update_sprint_item() { # $1=worktree root → prints none|updated|already|missing
|
|
417
468
|
local root="$1" f found=""
|
|
418
469
|
[ -n "$sprint" ] || { printf 'none'; return 0; }
|
|
419
470
|
local dir="$root/${SPRINT_DIR#/}"
|
|
@@ -431,15 +482,8 @@ update_sprint_annotation() { # $1=worktree root → prints none|updated|already|
|
|
|
431
482
|
index($0, "[" slug "]") == 0 { print; next }
|
|
432
483
|
{
|
|
433
484
|
line = $0
|
|
434
|
-
# Check the box
|
|
485
|
+
# Check the box. Nothing else on the line is touched.
|
|
435
486
|
sub(/\[ \]/, "[x]", line)
|
|
436
|
-
# Update or add status annotation
|
|
437
|
-
if (index(line, "<!--") == 0) {
|
|
438
|
-
line = line " <!-- status: delivered -->"
|
|
439
|
-
} else {
|
|
440
|
-
if (line ~ /status:[ \t]*[a-z-]+/) sub(/status:[ \t]*[a-z-]+/, "status: delivered", line)
|
|
441
|
-
else sub(/-->/, ", status: delivered -->", line)
|
|
442
|
-
}
|
|
443
487
|
print line
|
|
444
488
|
}
|
|
445
489
|
' "$found")
|
|
@@ -502,8 +546,8 @@ apply_local_writes() { # $1=root → sets phase_report record_report index_repo
|
|
|
502
546
|
# Step 5 — move the index symlink (best effort).
|
|
503
547
|
index_report=$(move_index_symlink "$root")
|
|
504
548
|
|
|
505
|
-
# Step 6 —
|
|
506
|
-
sprint_report=$(
|
|
549
|
+
# Step 6 — tick the sprint item.
|
|
550
|
+
sprint_report=$(update_sprint_item "$root")
|
|
507
551
|
return 0
|
|
508
552
|
}
|
|
509
553
|
|
|
@@ -517,7 +561,10 @@ bookbr="plot/deliver-$slug"
|
|
|
517
561
|
tmpwt="$wt_root/.plot-deliver-$slug.$$"
|
|
518
562
|
# -B: a leftover branch from an earlier failed run must not block this one.
|
|
519
563
|
git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
|
|
520
|
-
|| die "could not prepare a booking worktree at $tmpwt
|
|
564
|
+
|| die "could not prepare a booking worktree at $tmpwt.
|
|
565
|
+
Most often origin/$MAIN is not fetched, or '$bookbr' is checked out in
|
|
566
|
+
another worktree. Check both: git fetch origin $MAIN && git worktree list
|
|
567
|
+
Nothing has been written locally; the plan is untouched."
|
|
521
568
|
|
|
522
569
|
cleanup() {
|
|
523
570
|
git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
|
|
@@ -544,7 +591,9 @@ if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
|
|
|
544
591
|
else
|
|
545
592
|
if ! git -C "$tmpwt" -c "user.name=$who" commit -q -m "plot: deliver $slug"; then
|
|
546
593
|
cleanup
|
|
547
|
-
die "could not commit the delivery
|
|
594
|
+
die "could not commit the delivery.
|
|
595
|
+
See what git refused: git -C $tmpwt status
|
|
596
|
+
Nothing was pushed; re-run this — it is idempotent."
|
|
548
597
|
fi
|
|
549
598
|
|
|
550
599
|
push_out=$(bash "$script_dir/plot-push-main.sh" "$bookbr" "$MAIN" 2>&1)
|
|
@@ -580,4 +629,12 @@ else
|
|
|
580
629
|
fi
|
|
581
630
|
|
|
582
631
|
echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
|
|
632
|
+
# THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
|
|
633
|
+
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
|
|
634
|
+
# interrupted run can be repeated on the same licence: this script documents
|
|
635
|
+
# re-running as the repair for any interruption after its irreversible step,
|
|
636
|
+
# and a receipt spent at the gate would refuse that repair in the case it is
|
|
637
|
+
# most needed. One authorisation, one completed action.
|
|
638
|
+
spend_action_receipt "plot-deliver.sh"
|
|
639
|
+
|
|
583
640
|
exit 0
|
package/plot-dispatch.sh
CHANGED
|
@@ -150,6 +150,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
|
150
150
|
# shellcheck source=plot-worker-state.sh
|
|
151
151
|
. "$script_dir/plot-worker-state.sh"
|
|
152
152
|
|
|
153
|
+
# The controller receipt, for `spend_action_receipt` below. Sourced from the
|
|
154
|
+
# ONE file that holds both receipt kinds, for the reason that file states: the
|
|
155
|
+
# gate and the owners must agree on where a receipt lives.
|
|
156
|
+
# shellcheck source=plot-state-receipt.sh
|
|
157
|
+
. "$script_dir/plot-state-receipt.sh"
|
|
158
|
+
|
|
153
159
|
# The ONE answer to "did the host merge ANY PR for this branch?" — `pr_merged`,
|
|
154
160
|
# read by `held_worktree` rather than derived from ancestry. Sourced for the
|
|
155
161
|
# same reason `plot-reap.sh` and `plot-release-refs.sh` source it: three callers
|
|
@@ -276,7 +282,11 @@ while [ $# -gt 0 ]; do
|
|
|
276
282
|
done
|
|
277
283
|
|
|
278
284
|
git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
|
|
279
|
-
[ -n "$slug" ] || [ "$mode" != dispatch ] || {
|
|
285
|
+
[ -n "$slug" ] || [ "$mode" != dispatch ] || {
|
|
286
|
+
echo "plot-dispatch: need a plan slug (usage: plot-dispatch.sh [--dry-run] <slug>)" >&2
|
|
287
|
+
echo " Which plans could be dispatched: /plot-pulse" >&2
|
|
288
|
+
exit 1
|
|
289
|
+
}
|
|
280
290
|
|
|
281
291
|
# ---------------------------------------------------------------------------
|
|
282
292
|
# Worker launch, and the identity it records
|
|
@@ -1168,14 +1178,38 @@ if [ "$mode" = "stop" ]; then
|
|
|
1168
1178
|
echo " Refusing to guess — stopping the wrong worker discards its work." >&2
|
|
1169
1179
|
exit 1
|
|
1170
1180
|
fi
|
|
1171
|
-
|
|
1172
|
-
|
|
1181
|
+
# ASK GIT WHICH WORKTREE HOLDS THE BRANCH, then fall back to the dispatch
|
|
1182
|
+
# path. `--restart` below already asks, and this did not: it rebuilt one path
|
|
1183
|
+
# from the branch name and reported that single path as though it were the
|
|
1184
|
+
# only place a desk could be. On 2026-09-07 the desk existed elsewhere — a
|
|
1185
|
+
# worktree made by hand, which is the population that never follows
|
|
1186
|
+
# dispatch's naming — and the refusal sent a reader to `kill`.
|
|
1187
|
+
#
|
|
1188
|
+
# A refusal that is confidently wrong is worse than one that is terse, so the
|
|
1189
|
+
# path-guess survives only as the LAST candidate and the refusal below says
|
|
1190
|
+
# which places were looked in.
|
|
1191
|
+
wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$stop_branch" '
|
|
1192
|
+
/^worktree / { path = substr($0, 10) }
|
|
1193
|
+
/^branch / { if (substr($0, 8) == want) { print path; exit } }')
|
|
1194
|
+
wt_guess="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
|
|
1195
|
+
[ -n "$wt" ] && [ -d "$wt" ] || wt="$wt_guess"
|
|
1196
|
+
if [ ! -d "$wt" ]; then
|
|
1197
|
+
echo "plot-dispatch: no worktree holds '$stop_branch' — nothing to stop." >&2
|
|
1198
|
+
echo " Asked git for every worktree, and looked at $wt_guess." >&2
|
|
1199
|
+
echo " If a worker is running somewhere this cannot see, that machine is" >&2
|
|
1200
|
+
echo " where to stop it: /plot-dispatch --status names the desks here." >&2
|
|
1201
|
+
echo " Nothing was killed." >&2
|
|
1202
|
+
exit 1
|
|
1203
|
+
fi
|
|
1173
1204
|
st=$(worker_state "$wt" "$stop_branch")
|
|
1174
1205
|
case "$st" in
|
|
1175
1206
|
running*)
|
|
1176
1207
|
pid=${st#running }
|
|
1177
1208
|
kill "$pid" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
|
|
1178
|
-
|| { echo "plot-dispatch: could not stop pid $pid" >&2
|
|
1209
|
+
|| { echo "plot-dispatch: could not stop pid $pid — it may have exited between the read and the signal, or belong to another user." >&2
|
|
1210
|
+
echo " Check it: ps -p $pid -o pid=,stat=,command=" >&2
|
|
1211
|
+
echo " Nothing else was written; the worktree and the claim stand." >&2
|
|
1212
|
+
exit 1; }
|
|
1179
1213
|
# The worktree and its claim are left in place: the branch is still taken,
|
|
1180
1214
|
# and deleting either would be the kind of write this design avoids.
|
|
1181
1215
|
echo " worktree kept at $wt — the claim stands until you release it"
|
|
@@ -1884,6 +1918,7 @@ fi
|
|
|
1884
1918
|
if [ -z "$plan_path" ]; then
|
|
1885
1919
|
if [ "$allow_local" = 1 ]; then
|
|
1886
1920
|
echo "plot-dispatch: no plan found for '$slug' — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
|
|
1921
|
+
echo " Check the slug: ls $PLAN_DIR_CFG | grep -i '$slug'" >&2
|
|
1887
1922
|
else
|
|
1888
1923
|
echo "plot-dispatch: no plan for '$slug' on $gate_ref — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
|
|
1889
1924
|
echo " A plan that exists only in this working tree has not been shared yet: push it first." >&2
|
|
@@ -1944,6 +1979,7 @@ case "$gate_phase" in
|
|
|
1944
1979
|
exit 1 ;;
|
|
1945
1980
|
delivered|released)
|
|
1946
1981
|
echo "plot-dispatch: plan '$slug' is already $gate_phase — its work is done." >&2
|
|
1982
|
+
echo " Nothing to dispatch. To start new work: /plot-idea" >&2
|
|
1947
1983
|
exit 1 ;;
|
|
1948
1984
|
"")
|
|
1949
1985
|
echo "plot-dispatch: cannot read the phase of '$slug' ($gate_source)." >&2
|
|
@@ -1951,6 +1987,8 @@ case "$gate_phase" in
|
|
|
1951
1987
|
exit 1 ;;
|
|
1952
1988
|
*)
|
|
1953
1989
|
echo "plot-dispatch: plan '$slug' is in phase '$gate_phase', not Approved." >&2
|
|
1990
|
+
echo " Correct the 'State:' line in the plan and push it, or approve it:" >&2
|
|
1991
|
+
echo " /plot-approve $slug" >&2
|
|
1954
1992
|
exit 1 ;;
|
|
1955
1993
|
esac
|
|
1956
1994
|
|
|
@@ -1962,6 +2000,7 @@ case "$gate_impl" in
|
|
|
1962
2000
|
same-branch)
|
|
1963
2001
|
echo "plot-dispatch: plan '$slug' records 'Impl: same branch' — plan and code" >&2
|
|
1964
2002
|
echo " travel on one branch, so there is nothing to fan out." >&2
|
|
2003
|
+
echo " Implement on that branch instead: /plot-implement $slug" >&2
|
|
1965
2004
|
exit 1 ;;
|
|
1966
2005
|
other-repo)
|
|
1967
2006
|
echo "plot-dispatch: plan '$slug' records 'Impl: other repo' — implementation" >&2
|
|
@@ -1974,6 +2013,8 @@ case "$gate_impl" in
|
|
|
1974
2013
|
*)
|
|
1975
2014
|
echo "plot-dispatch: plan '$slug' records an unrecognised 'Impl:' answer" >&2
|
|
1976
2015
|
echo " ('$gate_impl'). Refusing rather than guessing." >&2
|
|
2016
|
+
echo " Set the plan's 'Impl:' line to one of: own branches, same branch," >&2
|
|
2017
|
+
echo " other repo, none — then push it." >&2
|
|
1977
2018
|
exit 1 ;;
|
|
1978
2019
|
esac
|
|
1979
2020
|
|
|
@@ -2403,7 +2444,8 @@ write_started_record() { # $@ = branches
|
|
|
2403
2444
|
# would carry the symlink and leave the record behind.
|
|
2404
2445
|
rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
2405
2446
|
if [ -z "$rel" ]; then
|
|
2406
|
-
echo "plot-dispatch: $plan_file is outside the repository root" >&2
|
|
2447
|
+
echo "plot-dispatch: $plan_file is outside the repository root ($repo_root)." >&2
|
|
2448
|
+
echo " Move the plan under $PLAN_DIR_CFG inside this checkout and re-run." >&2
|
|
2407
2449
|
return 1
|
|
2408
2450
|
fi
|
|
2409
2451
|
|
|
@@ -2419,6 +2461,9 @@ write_started_record() { # $@ = branches
|
|
|
2419
2461
|
# one. It is disposable by construction — created here, pushed, deleted.
|
|
2420
2462
|
if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
|
|
2421
2463
|
echo "plot-dispatch: could not prepare a booking worktree at $tmpwt" >&2
|
|
2464
|
+
echo " Most often origin/$MAIN is not fetched, or '$bookbr' is checked out in" >&2
|
|
2465
|
+
echo " another worktree. Check both: git fetch origin $MAIN && git worktree list" >&2
|
|
2466
|
+
echo " The branches were dispatched; only the plan's Started record is missing." >&2
|
|
2422
2467
|
return 1
|
|
2423
2468
|
fi
|
|
2424
2469
|
|
|
@@ -2442,6 +2487,7 @@ write_started_record() { # $@ = branches
|
|
|
2442
2487
|
fi
|
|
2443
2488
|
append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
|
|
2444
2489
|
echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
|
|
2490
|
+
echo " Add one to the plan (see .plot/templates/plan.md) and push it." >&2
|
|
2445
2491
|
rc=1
|
|
2446
2492
|
break
|
|
2447
2493
|
}
|
|
@@ -2463,6 +2509,7 @@ write_started_record() { # $@ = branches
|
|
|
2463
2509
|
fi
|
|
2464
2510
|
else
|
|
2465
2511
|
echo "plot-dispatch: $rel is not on origin/$MAIN" >&2
|
|
2512
|
+
echo " Push the plan to $MAIN first; the fleet reads plans from the shared ref." >&2
|
|
2466
2513
|
rc=1
|
|
2467
2514
|
fi
|
|
2468
2515
|
|
|
@@ -3165,3 +3212,13 @@ book_started ${claimed_now[@]+"${claimed_now[@]}"} || true
|
|
|
3165
3212
|
check_and_update_cap "$n_started"
|
|
3166
3213
|
|
|
3167
3214
|
print_summary "$n_dispatched" "$n_reused" "$n_skipped" "$n_started"
|
|
3215
|
+
|
|
3216
|
+
# THE RECEIPT IS SPENT HERE, on the fan-out COMPLETING — never at the gate.
|
|
3217
|
+
# `plot-controller-gate.sh` clears on a receipt and LEAVES it, so a run that
|
|
3218
|
+
# died partway can be repeated on the same licence. One authorisation, one
|
|
3219
|
+
# completed action.
|
|
3220
|
+
#
|
|
3221
|
+
# ONLY THE FAN-OUT SPENDS ONE. `--status`, `--dry-run`, `--stop`, `--restart`,
|
|
3222
|
+
# `--start` and `--migrate` all return before this line, and the gate exempts
|
|
3223
|
+
# each of them: they are reads, or writes no endpoint owns.
|
|
3224
|
+
spend_action_receipt "plot-dispatch.sh"
|