@plot-pm/board 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
25
  "plot-agent-monitor.sh",
26
+ "plot-agent-manifest.sh",
26
27
  "plot-build-monitor.sh",
27
28
  "plot-monitor-subject.sh",
28
29
  "plot-approve.sh",
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE writer that clears an agent manifest's `branch` field.
3
+ #
4
+ # SOURCED, NOT RUN — the shape `plot-worker-state.sh` and `plot-pr-merged.sh`
5
+ # already take. Two scripts clear an assignment and must clear it one way:
6
+ #
7
+ # - `plot-worker-loop.sh` clears its own manifest when a slice finishes, so
8
+ # the window before the next hand-over is observable as `branch: ""`;
9
+ # - `plot-dispatch.sh --release <branch>` clears every manifest naming an
10
+ # abandoned slice, beside deleting the claim ref.
11
+ #
12
+ # The function lived inside `plot-worker-loop.sh` until the second caller
13
+ # arrived. The loop is a script, not a library, so sourcing it would run it; the
14
+ # body moved here unchanged and the loop sources this file instead.
15
+ #
16
+ # Defines one function and does nothing else on load.
17
+
18
+ # Clear `branch` when a slice finishes, so the window before the next one is
19
+ # observable.
20
+ #
21
+ # WHY THIS EXISTS. `free = process alive AND manifest names no branch`, and the
22
+ # second half was unreachable. `seal_declaration` runs the moment a branch is
23
+ # done; `update_manifest_on_hop` runs after `--next` answers and a worktree is
24
+ # built. Between those two points the agent genuinely holds no slice and the
25
+ # manifest still named the last one, so `isFree`'s empty-branch arm — written,
26
+ # exported and unit-tested since `a-dispatch-asks-for-a-free-agent` — had no
27
+ # production caller that could ever satisfy it. Measured 2026-09-02: 2
28
+ # manifests on this estate, neither ever carrying `branch: ""`.
29
+ #
30
+ # `branch` AND ONLY `branch`. `worktree` still names the desk the agent is
31
+ # sitting at — it has not moved, and clearing it would take the transcript join
32
+ # and the liveness check with it, since both are keyed on the worktree path.
33
+ # `wavesCount` counts hops and no hop has happened yet. The node one-liner
34
+ # round-trips the whole object, so every other field survives verbatim, the same
35
+ # property `update_manifest_on_hop` records.
36
+ #
37
+ # ADDED, NOT SUBSTITUTED. The hop still rewrites `branch` and `worktree`
38
+ # together; this writes the empty value that sits between two slices. A worker
39
+ # that finishes its last branch exits with the manifest cleared and the exit
40
+ # trap removes the file, so the empty value is never a leftover.
41
+ #
42
+ # ABSENT IS NOT A FAILURE. No manifest — a hand-started loop, an older
43
+ # dispatcher — means there is nothing to clear and nothing to report, so this
44
+ # returns 0 like `update_manifest_on_hop` does.
45
+ clear_manifest_branch() { # $1=manifest
46
+ local manifest="$1"
47
+ [ -f "$manifest" ] || return 0
48
+
49
+ local tmp="$manifest.plot-free-tmp"
50
+ node -e '
51
+ const fs = require("fs");
52
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
53
+ manifest.branch = "";
54
+ fs.writeFileSync(process.argv[2], JSON.stringify(manifest, null, 2) + "\n");
55
+ ' "$manifest" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
56
+
57
+ mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
58
+ }
package/plot-approve.sh CHANGED
@@ -227,7 +227,25 @@ else
227
227
  pr_branch="idea/$slug"
228
228
  fi
229
229
 
230
- pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>/dev/null) || pr_json=""
230
+ # THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
231
+ # with `state: NONE` when the host answered that the branch has no PR, and
232
+ # non-zero when the host could not be asked: 3 for a refused or failed call
233
+ # (a rate limit included), 4 for a backend with no answer at all. Only the
234
+ # first is an absence. The other two stop here with the host's own words and
235
+ # name no repair to the branch, because nothing about the branch was read.
236
+ pr_err_file=$(mktemp "${TMPDIR:-/tmp}/plot-approve-pr.XXXXXX")
237
+ pr_rc=0
238
+ pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
239
+ pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
240
+ if [ "$pr_rc" = 4 ]; then
241
+ die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
242
+ ${pr_err:-The host adapter gave no reason.}
243
+ This backend cannot report a PR's state, so the approval cannot read its gate. The plan was not approved and its phase is unchanged."
244
+ elif [ "$pr_rc" != 0 ]; then
245
+ die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
246
+ ${pr_err:-The host adapter gave no reason.}
247
+ The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
248
+ fi
231
249
  [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
232
250
  pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
233
251
  pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
@@ -420,6 +438,10 @@ append_approved_line() { # $1=in $2=out $3=record
420
438
  insert = start
421
439
  for (i = start + 1; i <= n; i++) {
422
440
  if (lines[i] ~ /^##[ \t]/) break
441
+ # An HTML comment ends the writable region. Checked BEFORE the
442
+ # placeholder arms so a commented-out `- **Approved:**` template line
443
+ # is never mistaken for the slot to fill.
444
+ if (lines[i] ~ /<!--/) break
423
445
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Approved:\*\*[ \t]*$/) { slot = i; break }
424
446
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
425
447
  }
package/plot-budget.sh CHANGED
@@ -221,7 +221,7 @@ BUDGET_FALLBACK_WINDOW_MS=3600000
221
221
  # which is a reading about whichever pool was spent last — so a caller deciding
222
222
  # whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
223
223
  # and this is why.
224
- budget_rate() {
224
+ budget_rate_read() {
225
225
  local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
226
226
  local path
227
227
  [ -n "$now" ] || now="$(budget_now_ms)"
@@ -321,6 +321,171 @@ budget_rate() {
321
321
  ' "$path" 2>/dev/null || echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
322
322
  }
323
323
 
324
+ # THE SAME ANSWER IS SCANNED FOR ONCE PER PROCESS, and that is the whole of this
325
+ # memo. Measured on `quatico/quaweb-website` at Plot 2.19.0: `budget.tsv` holds
326
+ # 312589 lines across 17.6 MB, one read of it takes **516 ms** against 5 ms over
327
+ # fifty lines, and a single `pr-list` calls this three times for the same answer.
328
+ # The file is append-only and a process that asks twice is asking about the same
329
+ # window, so the second scan buys nothing a caller can observe.
330
+ #
331
+ # THE MEMO IS A FILE, AND A VARIABLE ALONE COULD NOT HAVE WORKED. Every caller
332
+ # in `plot-host.sh` writes `rate="$(budget_rate ...)"`, and a command
333
+ # substitution is a SUBSHELL: a variable the function sets inside one dies when
334
+ # the substitution closes. Measured on this branch with a counter — three
335
+ # substituted calls on one key scanned the ledger 3 times with a variable memo
336
+ # in place, against 1 for three direct calls. A variable memo is not slow at the
337
+ # call sites that exist, it is ABSENT from them, and every behavioural test
338
+ # still passes because the three answers are identical.
339
+ #
340
+ # `$$`, NOT `BASHPID`, IS THE SCOPE. `$$` is the invoking shell's pid and is
341
+ # deliberately NOT updated inside a subshell, so it names the process TREE —
342
+ # which is exactly "the life of the process" this memo is bounded by. `BASHPID`
343
+ # tracks the fork and would give every substitution its own empty cache, which
344
+ # is the variable memo's defect with an extra file write.
345
+ #
346
+ # THE COST IS PAID BACK ABOUT TWO THOUSAND TIMES. Measured here: a builtin
347
+ # `read` of one memo line is 82 us and a write 153 us, against 188 ms for one
348
+ # scan of a 40000-line ledger and the 516 ms reported above. The read is a
349
+ # builtin with no fork, which is what keeps it in that range.
350
+ #
351
+ # PER PROCESS IS THE WHOLE BOUND. There is no TTL, no `stat` of the record and
352
+ # no invalidation hook, because a `plot-host.sh` invocation is short-lived and a
353
+ # memo that tried to notice the file moving would re-`stat` on every call — the
354
+ # cost this exists to remove, re-introduced in a smaller form. `budget_append`
355
+ # writes between two reads and the second read is deliberately served the first
356
+ # one's answer.
357
+ #
358
+ # THE KEY IS THE TRIPLE, NEVER ONE FIELD OF IT. An EMPTY bucket means *every
359
+ # bucket* and is a different question from any named one — `budget_rate_read`
360
+ # says so in its own words above, and the two live in one process: measured with
361
+ # a probe, one `pr-list` asks `(github,jwloka,'')` for the concurrency bound and
362
+ # `(github,jwloka,graphql)` for the transport choice. A memo keyed on less than
363
+ # the triple answers the first with the second's reading.
364
+ #
365
+ # AN EXPLICIT `now` BYPASSES THE MEMO ENTIRELY, and a reader will ask why. It is
366
+ # a FOURTH question rather than a fourth key: a caller naming a moment is asking
367
+ # what the record looked like THEN, and every window boundary above is computed
368
+ # from it. Keying on it would make every lookup a miss, because the callers that
369
+ # omit it get `budget_now_ms()` and differ by milliseconds — a memo that is dead
370
+ # code and still passes every behavioural test. Serving a memo across different
371
+ # `now` values would answer a named moment with another one's window. Only the
372
+ # callers that omit it — every one in `plot-host.sh` — are memoised.
373
+ #
374
+ # NOT-YET-COMPUTED AND COMPUTED-TO-EMPTY ARE TWO STATES, and the FILE'S
375
+ # EXISTENCE is what separates them. The zero object is a well-formed answer for
376
+ # a missing record and for a `budget_path` that failed, so it is CACHED like any
377
+ # other — a memo that treated it as no-answer-worth-keeping would restore the
378
+ # scan on exactly the machines with nothing to scan. The entry is never empty:
379
+ # `budget_rate_read` prints a JSON object on every path, so a zero-byte entry
380
+ # means a torn write and is re-read rather than served.
381
+ #
382
+ # PUBLISHED BY `mv`, NEVER BY `>`, for `budget_slot_acquire`'s reason one
383
+ # paragraph down: a redirect creates the NAME before the CONTENT, so a sibling
384
+ # subshell can open the file and read half an answer. The rename is atomic
385
+ # within a directory, so the name and the answer arrive together.
386
+ budget_rate() {
387
+ local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
388
+
389
+ # A caller that named a moment is asking a different question. Straight
390
+ # through, neither read nor written.
391
+ if [ -n "$now" ]; then
392
+ budget_rate_read "$connector" "$account" "$bucket" "$now"
393
+ return $?
394
+ fi
395
+
396
+ local key slot
397
+ key="${connector}|${account}|${bucket}"
398
+ # Every character a variable name may not hold becomes `_`. Two distinct
399
+ # triples could collide only by differing in punctuation alone, which no
400
+ # connector, account or bucket name does.
401
+ slot="_budget_rate_memo_$(printf '%s' "$key" | LC_ALL=C tr -c '[:alnum:]_' '_')"
402
+
403
+ # TIER ONE, FREE: the same shell asking twice. SET, NOT NON-EMPTY — the zero
404
+ # object is a real answer and an empty one is a state this memo never stores.
405
+ if eval "[ -n \"\${${slot}+set}\" ]"; then
406
+ eval "printf '%s\\n' \"\${${slot}}\""
407
+ return 0
408
+ fi
409
+
410
+ # TIER TWO, 82 us: a subshell asking what its parent already asked. This is
411
+ # the tier that fires at every call site `plot-host.sh` actually has.
412
+ local dir file answer
413
+ dir="$(budget_memo_dir)" || dir=''
414
+ if [ -n "$dir" ]; then
415
+ file="$dir/$slot"
416
+ # `-s`, NOT `-f`. A zero-byte entry is a torn write, never an answer.
417
+ if [ -s "$file" ]; then
418
+ IFS= read -r answer < "$file" 2>/dev/null || answer=''
419
+ if [ -n "$answer" ]; then
420
+ eval "${slot}=\$answer"
421
+ printf '%s\n' "$answer"
422
+ return 0
423
+ fi
424
+ fi
425
+ fi
426
+
427
+ local rc
428
+ # THE EXIT CODE IS THE READ'S, never this wrapper's. `budget_rate_read`
429
+ # returns 0 on every path today and its callers test the CONTENT, so a memo
430
+ # that invented an exit status would be the one place the two disagree.
431
+ answer="$(budget_rate_read "$connector" "$account" "$bucket")"; rc=$?
432
+ if [ "$rc" -ne 0 ]; then
433
+ # A read that failed is not an answer, so nothing is remembered: the next
434
+ # caller asks again rather than inheriting a failure for the process life.
435
+ printf '%s\n' "$answer"
436
+ return "$rc"
437
+ fi
438
+ eval "${slot}=\$answer"
439
+ # NEVER FAILS ITS CALLER, `budget_append`'s rule for the same reason: the memo
440
+ # is an optimisation beside an answer that is already correct, so a cache that
441
+ # cannot be written must not turn a good reading into a failed one.
442
+ if [ -n "$dir" ] && mkdir -p "$dir" 2>/dev/null; then
443
+ if printf '%s\n' "$answer" >"$file.$BASHPID.tmp" 2>/dev/null; then
444
+ mv -f "$file.$BASHPID.tmp" "$file" 2>/dev/null || rm -f "$file.$BASHPID.tmp" 2>/dev/null || true
445
+ fi
446
+ fi
447
+ printf '%s\n' "$answer"
448
+ return 0
449
+ }
450
+
451
+ # Where THIS PROCESS's memoised rates live, and it is deliberately not beside
452
+ # the record. `$PLOT_BUDGET_HOME/memo/<pid>` — same override as the record and
453
+ # the slots, so a test pointing `PLOT_BUDGET_HOME` at a sandbox gets a sandboxed
454
+ # cache too, and a `$PLOT_BUDGET_HOME` that cannot be resolved means no cache
455
+ # rather than a cache in the wrong place.
456
+ #
457
+ # `$$` IS THE DIRECTORY NAME because it is the one identifier that is stable
458
+ # across a command substitution — see `budget_rate` above, where that property
459
+ # is the whole reason this file exists. A pid is reused by the kernel after the
460
+ # process ends, so an entry could in principle be inherited by a later,
461
+ # unrelated process holding the same pid. That is bounded by `budget_memo_clear`
462
+ # below, which the adapter calls on exit, and it is why the cache holds a
463
+ # DERIVED reading rather than anything a caller could act on irreversibly: the
464
+ # worst case is one stale rate, which is the same staleness the memo grants
465
+ # within a process by design.
466
+ budget_memo_dir() {
467
+ local home="${PLOT_BUDGET_HOME:-}"
468
+ if [ -z "$home" ]; then
469
+ [ -n "${HOME:-}" ] || return 1
470
+ home="$HOME/.plot/state"
471
+ fi
472
+ printf '%s\n' "$home/memo/$$"
473
+ }
474
+
475
+ # Removes this process's memo directory. Called on exit by the adapter, so a
476
+ # long-lived machine does not accumulate one directory per `plot-host.sh` call.
477
+ #
478
+ # NEVER FAILS ITS CALLER. It runs in a trap beside work that has already
479
+ # happened, and a cache that cannot be cleared must not change an exit status.
480
+ budget_memo_clear() {
481
+ local dir
482
+ dir="$(budget_memo_dir)" || return 0
483
+ case "$dir" in
484
+ */memo/[0-9]*) rm -rf "$dir" 2>/dev/null || true ;;
485
+ esac
486
+ return 0
487
+ }
488
+
324
489
  # ── The concurrency bound ────────────────────────────────────────────────────
325
490
  #
326
491
  # HOW MANY CALLS THIS ACCOUNT HAS OPEN AT ONCE, bounded across PROCESSES. The
package/plot-config.sh CHANGED
@@ -80,6 +80,13 @@
80
80
  # here can invoke a skill. Absent (or `none`) = the button
81
81
  # refuses and names this key as the fix, rather than
82
82
  # accepting the click and doing nothing.
83
+ # Interrogate command how the board runs `/plot-panel <plan path>` on a Draft
84
+ # plan (the card's `Interrogate` button); the prompt is
85
+ # appended as one argument and names a FILE the board
86
+ # wrote. REQUIRED for the same reason as `Idea command`:
87
+ # a panel fans out N agents reading a plan, and no script
88
+ # can do that. Absent (or `none`) = the button renders
89
+ # disabled and names this key as the fix.
83
90
  # Brief command how /plot-dispatch runs an agent headless to WRITE a
84
91
  # missing hand-off brief. The prompt is appended as one
85
92
  # argument and asks for `/plot-implement <slug>`, whose
@@ -95,6 +102,16 @@
95
102
  # Hosts plans yes | no (no = refuse plan files)
96
103
  # Tracker plot | jira | github-issues | linear (+ URL)
97
104
  # (plot = plans in this repo ARE the tracker; absent = same)
105
+ # Tracker delivered status
106
+ # the status word a plan's issues are set to when the plan
107
+ # reaches Delivered, in the tracker's own vocabulary
108
+ # (`In Review`, `Done`). Read by plot-issue-status.sh.
109
+ # Absent or empty = Delivered writes nothing — never an
110
+ # empty status, and never the released word instead.
111
+ # Tracker released status
112
+ # the same for Released. A team whose *Done* means
113
+ # *shipped* sets only this one; a team watching progress
114
+ # sets both.
98
115
  # Ticket prefixes the tracker project keys this repository's work lives
99
116
  # in, comma-separated (`PROJ-A, PROJ-B`). NOT
100
117
  # `Branch prefixes`, which sits next to it and holds
@@ -142,7 +159,23 @@ if [ "$cmd" != "get" ]; then
142
159
  exit 1
143
160
  fi
144
161
 
145
- root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
162
+ # THE CALLER'S ROOT IS TAKEN WHERE IT OFFERS ONE. `git rev-parse` is ~5 ms and
163
+ # this script runs once per config key, so a caller reading several keys pays
164
+ # for the same constant repeatedly. Measured on CI 2026-09-25: one board build
165
+ # spawned `git rev-parse --show-toplevel` 21 times out of 42 git processes
166
+ # total, which `plan-read-shape.test.mjs` caught as the spawn count crossing
167
+ # its bound. The board already exports `PLOT_REPO_ROOT` (`index.ts:65`) and
168
+ # `plot-deliver.sh` and `plot-issue-status.sh` already read it.
169
+ #
170
+ # IT MUST BE A DIRECTORY, and a wrong one falls back rather than failing: an
171
+ # exported stale path would otherwise make every config read answer from a
172
+ # repository that is not this one, silently. Asking git is the safe answer and
173
+ # stays the default for every caller that offers nothing.
174
+ if [ -n "${PLOT_REPO_ROOT:-}" ] && [ -d "$PLOT_REPO_ROOT" ]; then
175
+ root="$PLOT_REPO_ROOT"
176
+ else
177
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
178
+ fi
146
179
 
147
180
  # Find the first repo-root file that contains a ## Plot Config section.
148
181
  # CLAUDE.md wins for backwards compatibility; AGENTS.md is the modern fallback.
package/plot-deliver.sh CHANGED
@@ -5,7 +5,10 @@
5
5
  # --who the name recorded in the `Delivered:` line (default: git user.name)
6
6
  # <slug> the plan to deliver
7
7
  # Output: one `step:` line per step, then a machine-countable summary:
8
- # summary: phase=flipped record=written index=moved sprint=updated push=clean
8
+ # summary: phase=flipped record=written index=moved sprint=updated push=clean tracker=none
9
+ # `tracker=` is plot-issue-status.sh's outcome (none|written|no-target|
10
+ # unaskable|failed), or `skipped` when the push was rejected. It never
11
+ # changes the exit code: a failed status write is reported, not raised.
9
12
  # Exit 0 when the plan is Delivered on the default branch (whether this
10
13
  # run did the work or found it already done); 1 on a refusal or a
11
14
  # failure, with the reason on stderr.
@@ -654,6 +657,12 @@ git -C "$tmpwt" add -- "${ACTIVE_DIR#/}" >/dev/null 2>&1 || true
654
657
  git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
655
658
  [ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
656
659
 
660
+ # THE BOOKED PLAN, KEPT FOR THE TRACKER. The booking worktree is removed on
661
+ # every exit below, and the working tree may still read `Approved`; the issue
662
+ # status is decided from the file that reached the default branch.
663
+ booked_plan=$(mktemp "${TMPDIR:-/tmp}/plot-deliver-plan.XXXXXX")
664
+ cp "$tmpwt/$rel" "$booked_plan" 2>/dev/null || : > "$booked_plan"
665
+
657
666
  if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
658
667
  # THE IDEMPOTENT EXIT. Everything this run would have written was already
659
668
  # on the default branch, so there is nothing to push and nothing wrong.
@@ -694,13 +703,25 @@ else
694
703
  echo "plot-deliver: the delivery is committed on '$bookbr' but could not reach $MAIN." >&2
695
704
  echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
696
705
  git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
697
- echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
706
+ rm -f "$booked_plan"
707
+ # NOTHING REACHED THE DEFAULT BRANCH, so no status is owed yet.
708
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=skipped"
698
709
  exit 1
699
710
  fi
700
711
  fi
701
712
  fi
702
713
 
703
- echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
714
+ # THE TRACKER HEARS AFTER THE DELIVERY LANDED, and never decides it. The plan is
715
+ # delivered; the tracker holds a copy of one fact about it. Every outcome of
716
+ # plot-issue-status.sh, a failed write and an unreadable bundle included, is a
717
+ # report on the summary line and leaves this script's exit code alone.
718
+ tracker_out=$(bash "$script_dir/plot-issue-status.sh" "$booked_plan" 2>&1)
719
+ printf '%s\n' "$tracker_out" | grep -v '^summary: ' | sed '/^$/d; s/^/ tracker: /'
720
+ tracker_report=$(printf '%s' "$tracker_out" | sed -n 's/^summary: tracker=\([a-z-]*\).*/\1/p' | tail -1)
721
+ [ -n "$tracker_report" ] || tracker_report="failed"
722
+ rm -f "$booked_plan"
723
+
724
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=$tracker_report"
704
725
  # THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
705
726
  # `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
706
727
  # interrupted run can be repeated on the same licence: this script documents