@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.12.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. Reopen it or create a new one." ;;
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'. Run /plot-idea first, or push the 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, status: draft, branch: feature/slug -->
562
- # /plot-sprint READS these (`pr`, `status`, `branch`) and /plot-approve writes
563
- # them, so an approval that skips this makes `/plot-sprint status` wrong rather
564
- # than merely incomplete.
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 `status: approved`.
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
- line = line " <!-- pr: #" pr ", status: approved" (br != "" ? ", branch: " br : "") " -->"
593
- } else {
594
- if (line ~ /status:[ \t]*[a-z-]+/) sub(/status:[ \t]*[a-z-]+/, "status: approved", line)
595
- else sub(/-->/, ", status: approved -->", line)
596
- if (line ~ /pr:[ \t]*#?[0-9a-z]+/) sub(/pr:[ \t]*#?[0-9a-z]+/, "pr: #" pr, line)
597
- else sub(/<!--/, "<!-- pr: #" pr ",", line)
598
- if (br != "") {
599
- if (line ~ /branch:[ \t]*[^,>]+/) sub(/branch:[ \t]*[^,>]*[^,> \t]/, "branch: " br, line)
600
- else sub(/-->/, ", branch: " br " -->", line)
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 annotation. Never a progress file of its own.
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 update the sprint annotation${sprint:+ (sprint: $sprint)}"
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
- # Update the sprint item annotation for this plan.
416
- update_sprint_annotation() { # $1=worktree root → prints none|updated|already|missing
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 — update the sprint annotation.
506
- sprint_report=$(update_sprint_annotation "$root")
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 ] || { echo "plot-dispatch: need a plan slug" >&2; exit 1; }
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
- wt="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
1172
- [ -d "$wt" ] || { echo "plot-dispatch: no worktree for '$stop_branch' at $wt" >&2; exit 1; }
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; exit 1; }
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"