@plot-pm/board 0.9.1 → 0.11.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/plot-approve.sh CHANGED
@@ -345,8 +345,11 @@ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
345
345
  # (this repo has several, documenting the format) would otherwise have its
346
346
  # illustration rewritten too — a silent corruption of the very files that
347
347
  # specify the format.
348
- flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing to flip
349
- local f="$1"
348
+ #
349
+ # READS ONE FILE AND WRITES ANOTHER, rather than editing in place. It edited in
350
+ # place until 2026-09-02, which is what let the phase land without its record —
351
+ # see write_transition() below.
352
+ flip_phase() { # $1=in $2=out → 0 if it changed the file, 1 if there was nothing to flip
350
353
  awk '
351
354
  BEGIN { section = ""; done = 0 }
352
355
  /^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
@@ -363,10 +366,7 @@ flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing t
363
366
  }
364
367
  { print }
365
368
  END { exit (changed ? 0 : 1) }
366
- ' "$f" > "$f.plot-tmp"
367
- local rc=$?
368
- if [ "$rc" = 0 ]; then mv "$f.plot-tmp" "$f"; else rm -f "$f.plot-tmp"; fi
369
- return "$rc"
369
+ ' "$1" > "$2"
370
370
  }
371
371
 
372
372
  # Insert one `- **Approved:** ...` line into the plan's `## Status` section.
@@ -384,9 +384,11 @@ flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing t
384
384
  # plot-plan-meta.sh reads these records out of that section, so a line below it
385
385
  # parses as nothing at all — a record that exists on disk and not in the data is
386
386
  # worse than no record, because it looks written.
387
- append_approved_line() { # $1=file $2=date $3=who $4=channel
388
- local f="$1" line
389
- line="- **Approved:** $2, $3, $4"
387
+ #
388
+ # READS ONE FILE AND WRITES ANOTHER, for the reason flip_phase() gives.
389
+ append_approved_line() { # $1=in $2=out $3=record
390
+ local line
391
+ line="- **Approved:** $3"
390
392
  awk -v line="$line" '
391
393
  { lines[++n] = $0 }
392
394
  END {
@@ -408,8 +410,121 @@ append_approved_line() { # $1=file $2=date $3=who $4=channel
408
410
  if (!slot && i == insert) print line
409
411
  }
410
412
  }
411
- ' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
412
- mv "$f.plot-tmp" "$f"
413
+ ' "$1" > "$2"
414
+ }
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # THE TRANSITION — one value, decided in the domain, written whole or not at all
418
+ # ---------------------------------------------------------------------------
419
+ #
420
+ # WHAT THIS CLOSES. The phase and the record were steps 3 and 4 until
421
+ # 2026-09-02, and they were independent: `flip_phase` edited the file, and
422
+ # `append_approved_line` then edited it again. The second could fail — a plan
423
+ # with no `## Status` heading is its documented refusal — and the first had
424
+ # already landed. Measured on the delivery side 2026-08-20, where the same shape
425
+ # had the same bug: a plan carrying `Phase: Delivered` with no `Delivered:` line
426
+ # was filtered out of `plot-fleet-scan.sh` ENTIRELY, which reads its delivered
427
+ # window from the record itself. A phase without its record does not make a plan
428
+ # half-transitioned; it makes it invisible.
429
+ #
430
+ # SO THE TWO ARE ONE WRITE. The domain says so in a type: `transitions/plan.ts`
431
+ # gives `Decision` a required `phase` AND a required `record`, so a decision
432
+ # missing either does not compile. `plot-transition.mjs` carries that value out
433
+ # to here, and this pair of functions is the half that could still have broken
434
+ # it — both edits run against scratch copies, and the plan file is replaced only
435
+ # once both have succeeded.
436
+ #
437
+ # THE DOMAIN DECIDES, THIS PERFORMS. The phase word and the record's text come
438
+ # back from the bundle; the awk that knows where a `## Status` line lives stays
439
+ # here, because that is adaptation and it carries bug history worth keeping
440
+ # (append_started_line()'s placeholder repair, 2026-08-17).
441
+ #
442
+ # IDEMPOTENCE IS UNCHANGED, and it is still the source that answers. The domain
443
+ # reads the phase and the record THIS SCRIPT PARSED FROM THE FILE IT IS ABOUT TO
444
+ # WRITE, and answers `already` when both are present — the same question steps 3
445
+ # and 4 asked separately, asked once. No progress file appears here, because a
446
+ # progress file is exactly what would disagree with the repository when somebody
447
+ # intervened by hand between two runs.
448
+ #
449
+ # THE MERGE IS NOT PART OF IT, and it must not become one. Step 2 merges the PR,
450
+ # and a merged PR cannot be rolled back if a file write then fails. Atomicity
451
+ # stops at the host boundary; re-running is the repair, which is why every step
452
+ # still tests the source it would have written.
453
+ transition_mjs="$script_dir/board/plot-transition.mjs"
454
+
455
+ # Ask the domain for the transition, and refuse in its words.
456
+ #
457
+ # Called with the file's OWN parse rather than the caller's: on the `pr` flow
458
+ # those are different files, and the plan on the default branch is the one that
459
+ # counts.
460
+ decide_transition() { # $1=file $2=channel → prints "<Phase>\t<record>\t<write|already>\t<yes|no>"
461
+ local f="$1" channel="$2" m answer rc
462
+ [ -f "$transition_mjs" ] \
463
+ || { echo "plot-approve: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
464
+ m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
465
+ [ -n "$m" ] || { echo "plot-approve: cannot parse $f — refusing rather than guessing." >&2; return 1; }
466
+ answer=$(printf 'approve\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\n' \
467
+ "$slug" \
468
+ "$(printf '%s' "$m" | jq -r '.phase // ""')" \
469
+ "$(printf '%s' "$m" | jq -r '.review // ""')" \
470
+ "$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
471
+ "$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
472
+ "$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
473
+ "$today" "$who" "$channel" \
474
+ | node "$transition_mjs" 2>&1)
475
+ rc=$?
476
+ # Exit 1 is the domain's refusal and its sentence, tab-separated after the
477
+ # rule that fired. Exit 2 is this script handing it something unreadable,
478
+ # which no operator can act on — so it reports as the bug it is.
479
+ if [ "$rc" != 0 ]; then
480
+ if [ "$rc" = 1 ]; then
481
+ echo "plot-approve: $(printf '%s' "$answer" | cut -f2-)" >&2
482
+ else
483
+ echo "plot-approve: $answer" >&2
484
+ fi
485
+ return 1
486
+ fi
487
+ printf '%s' "$answer"
488
+ }
489
+
490
+ # Apply the decided transition: the phase and the record, or neither.
491
+ #
492
+ # BOTH EDITS RUN AGAINST SCRATCH FILES and the plan is replaced by one `mv`.
493
+ # An `## Status` section that cannot take the record leaves the file exactly as
494
+ # it was found — INCLUDING ITS PHASE — so re-running is still the repair, and
495
+ # the half-state that made a plan invisible cannot be reached from here.
496
+ #
497
+ # `$3` says whether the file already carries the record. A plan can carry one
498
+ # while its phase lags — written by hand, or an earlier run cut between the two
499
+ # writes — and appending a second would leave the block listing one approval
500
+ # twice. The domain returns the written record unchanged in that case, so the
501
+ # phase still flips and nothing is inserted.
502
+ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_report record_report
503
+ local f="$1" record="$2" recorded="$3" a="$1.plot-phase" b="$1.plot-record" flipped=0
504
+
505
+ if flip_phase "$f" "$a"; then flipped=1; else flipped=0; fi
506
+ # awk wrote `$a` either way; where nothing flipped it is a faithful copy, so
507
+ # the record still has a file to be inserted into.
508
+ [ -s "$a" ] || { rm -f "$a"; echo "plot-approve: could not read $rel" >&2; return 1; }
509
+
510
+ if [ "$recorded" = "yes" ]; then
511
+ mv "$a" "$f" || { rm -f "$a"; return 1; }
512
+ record_report="already"
513
+ else
514
+ if ! append_approved_line "$a" "$b" "$record"; then
515
+ rm -f "$a" "$b"
516
+ echo "plot-approve: $rel has no '## Status' section — nowhere to record the approval." >&2
517
+ echo " Nothing was written: the phase is not flipped either, because a phase" >&2
518
+ echo " with no record is invisible to the scan. Fix the section and re-run." >&2
519
+ return 1
520
+ fi
521
+ mv "$b" "$f" || { rm -f "$a" "$b"; return 1; }
522
+ rm -f "$a"
523
+ record_report="written"
524
+ fi
525
+
526
+ phase_report=$([ "$flipped" = 1 ] && echo flipped || echo already)
527
+ return 0
413
528
  }
414
529
 
415
530
  # Remove the `.plot/hold` entry for EVERY branch the plan names, and nothing
@@ -496,26 +611,27 @@ apply_local_writes() { # $1=root → sets phase_report record_report holds_repo
496
611
  local root="$1" f="$1/$rel"
497
612
  [ -f "$f" ] || { echo "plot-approve: $rel is not present in $root" >&2; return 1; }
498
613
 
499
- # Step 3 — flip the phase. Already-done test: the file no longer says Draft.
500
- if flip_phase "$f"; then phase_report="flipped"; else phase_report="already"; fi
501
-
502
- # Step 4 fill the Approved: record. Already-done test: it is non-empty in
503
- # THE FILE BEING WRITTEN, re-parsed here rather than trusted from the caller's
504
- # copy: on the `pr` flow those are different files, and the plan on the
505
- # default branch is the one that counts.
506
- local rec
507
- rec=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null | jq -r '.approved_raw // ""' 2>/dev/null)
508
- if [ -n "$rec" ]; then
614
+ # Step 3 — THE TRANSITION: the phase and its record, together or not at all.
615
+ #
616
+ # ONE STEP WHERE THERE WERE TWO, which is the whole of this change. The domain
617
+ # decides what the two lines say and whether they are owed at all; this writes
618
+ # them as one replacement of the file. Already-done test: the domain answers
619
+ # `already` when the file it is about to write ALREADY carries both re-parsed
620
+ # here rather than trusted from the caller's copy, because on the `pr` flow
621
+ # those are different files and the plan on the default branch is the one that
622
+ # counts.
623
+ local channel="plan-PR #$pr_number merged"
624
+ [ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
625
+ local decided record action recorded
626
+ decided=$(decide_transition "$f" "$channel") || return 1
627
+ record=$(printf '%s' "$decided" | cut -f2)
628
+ action=$(printf '%s' "$decided" | cut -f3)
629
+ recorded=$(printf '%s' "$decided" | cut -f4)
630
+ if [ "$action" = "already" ]; then
631
+ phase_report="already"
509
632
  record_report="already"
510
633
  else
511
- local channel="plan-PR #$pr_number merged"
512
- [ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
513
- if append_approved_line "$f" "$today" "$who" "$channel"; then
514
- record_report="written"
515
- else
516
- echo "plot-approve: $rel has no '## Status' section — nowhere to record the approval" >&2
517
- return 1
518
- fi
634
+ write_transition "$f" "$record" "$recorded" || return 1
519
635
  fi
520
636
 
521
637
  # Step 5 — clear the holds, keyed by branch.
package/plot-budget.sh ADDED
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the budget record's SHELL half — what this computer has spent,
3
+ # per (connector, account, bucket).
4
+ #
5
+ # SOURCED, NOT RUN, by `plot-host.sh`. `. "$here/plot-budget.sh"` defines
6
+ # `budget_path`, `budget_append` and `budget_rate`; the file does nothing else
7
+ # on load. Same shape and same reason as `plot-pr-merged.sh` and
8
+ # `plot-worker-state.sh`: the caller parses its own `$@`, so a file that ran an
9
+ # argument parser at load time could not be sourced.
10
+ #
11
+ # IT APPENDS AND READS, AND IT NEVER PRUNES. Truncation is the one write that is
12
+ # not an append, and it belongs to the `BudgetRecord` port's `truncate()` — a
13
+ # second pruning path in shell would rewrite the file while the port's reader
14
+ # believed it held the lines it had just proven dead. The shell writes the
15
+ # record; the domain is what cleans it.
16
+ #
17
+ # WHY A SECOND IMPLEMENTATION OF A FORMAT THE DOMAIN ALREADY ENCODES. The
18
+ # spenders are eleven shell scripts, a board and a person at a terminal, and
19
+ # only the board is TypeScript. A shell that had to start `node` to record one
20
+ # call would add ~40 ms and a runtime dependency to every host call plot makes —
21
+ # on the hot path, to write one line. So the format is written twice and pinned
22
+ # by a test that decodes shell output with `decodeEntry`: the drift risk is
23
+ # real, and a contract test is the answer to it rather than a slower appender.
24
+ #
25
+ # THE FORMAT IS `packages/domain/src/entities/budget.ts`'s AND NOT THIS FILE'S.
26
+ # Ten tab-separated fields behind a `b1` marker:
27
+ #
28
+ # b1 <connector> <account> <bucket> <at-ms> <spent> <limit> <remaining> <reset-ms> <basis>
29
+ #
30
+ # `-` is the absent marker, and ABSENT IS NOT ZERO: a `remaining` of 0 means
31
+ # the bucket is spent and every call is refused, while `-` means the connector
32
+ # did not say. Writing null as 0 would make silence read as exhaustion.
33
+ #
34
+ # APPEND-ONLY AND LOCK-FREE, AND THE LINE CAP IS WHY. Concurrent `O_APPEND` is
35
+ # atomic only below `PIPE_BUF`, which `getconf PIPE_BUF /` reports as **512**
36
+ # on this fleet's macOS machines — not the 4096 a reader assuming Linux would
37
+ # take. So every line is measured before it is written and an over-long one is
38
+ # REFUSED rather than shortened: a torn line loses the concurrent writer's line
39
+ # too, so dropping one spend is cheaper than corrupting another's.
40
+ #
41
+ # THE BUCKET IS THE CONNECTOR'S OWN WORD, AND ONE CONNECTOR HAS SEVERAL. GitHub
42
+ # meters `core` and `graphql` as independent 5000-request pools, named by
43
+ # `X-RateLimit-Resource` on the response of a call that was going to happen
44
+ # anyway. Measured 2026-09-01 on one account at one moment: `core` 4990 of 5000,
45
+ # `graphql` **0** of 5000. A record keyed to one undifferentiated pool describes
46
+ # neither — it reports room while every `gh pr` call is refused, and refuses
47
+ # calls that would have gone to the pool with 4990 left. Nothing here validates
48
+ # the name: a connector nobody has written an adapter for names a third thing.
49
+ #
50
+ # THE RECORD IS THE COMPUTER'S, NOT THE CHECKOUT'S. Measured 2026-09-01: two
51
+ # GitHub checkouts on this computer share the account `jwloka`, so a
52
+ # per-checkout `.plot/state/` would let each read a full 5000 while the other
53
+ # spent it — the over-spend the record exists to prevent, reproduced by storing
54
+ # it in the wrong place. `$PLOT_BUDGET_HOME` is the ONE override, and it is the
55
+ # same variable `budget-file.ts` reads.
56
+
57
+ # The record's directory, then its file. Nothing here reads a repository root, a
58
+ # git directory or a working directory — that absence is the fix.
59
+ budget_path() {
60
+ local home="${PLOT_BUDGET_HOME:-}"
61
+ if [ -z "$home" ]; then
62
+ [ -n "${HOME:-}" ] || return 1
63
+ home="$HOME/.plot/state"
64
+ fi
65
+ printf '%s\n' "$home/budget.tsv"
66
+ }
67
+
68
+ # The most bytes one appended line may occupy, newline included. Mirrors
69
+ # `MAX_LINE_BYTES` in `packages/domain/src/entities/budget.ts`, and a test pins
70
+ # the two together.
71
+ BUDGET_MAX_LINE_BYTES=512
72
+
73
+ # Strips what the format cannot carry from a key part. A tab would add a field
74
+ # and a newline would add a line, so both are replaced rather than escaped: the
75
+ # only inputs are a connector, an account and a bucket name, none of which any
76
+ # connector spells with whitespace.
77
+ budget_clean() {
78
+ printf '%s' "$1" | tr '\t\r\n' '___'
79
+ }
80
+
81
+ # A number, or the absent marker for anything that is not one. A non-numeric
82
+ # reading is ABSENT rather than zero, for the reason above.
83
+ budget_num() {
84
+ case "$1" in
85
+ ''|'-') printf '%s' '-' ;;
86
+ *) if [[ "$1" =~ ^-?[0-9]+$ ]]; then printf '%s' "$1"; else printf '%s' '-'; fi ;;
87
+ esac
88
+ }
89
+
90
+ # Now, in epoch milliseconds.
91
+ #
92
+ # `EPOCHREALTIME` FIRST, BECAUSE IT COSTS NO PROCESS. It is a bash 5 builtin
93
+ # holding `seconds.microseconds`, so the milliseconds are a substring — and this
94
+ # runs beside every host call, where a `fork`+`exec` per line is a cost the
95
+ # record should not impose.
96
+ #
97
+ # THE FALLBACK IS SECONDS, AND IT IS NOT MERELY DEFENSIVE. macOS ships bash 3.2
98
+ # at `/bin/bash`, where `EPOCHREALTIME` does not exist; `date +%s%3N` is GNU-only
99
+ # and prints a literal `3N` on the BSD `date` beside it, which would write a
100
+ # timestamp no reader can decode. So seconds are read portably and multiplied.
101
+ #
102
+ # SECOND RESOLUTION COSTS A SPAN, NOT A COUNT. Several lines inside one second
103
+ # share a timestamp, so a window holding only those reports `spanMs: 0` and
104
+ # `perHour: null` — an absent rate, which is the honest answer to *how fast* when
105
+ # the record cannot yet say. The spend COUNT is exact either way, and a window
106
+ # wide enough to divide a cadence by spans minutes rather than milliseconds.
107
+ budget_now_ms() {
108
+ if [ -n "${EPOCHREALTIME:-}" ]; then
109
+ # `1788341828.708210` → `1788341828708`. The locale decides the separator,
110
+ # so both are matched rather than assuming a dot.
111
+ local whole frac
112
+ whole="${EPOCHREALTIME%%[.,]*}"
113
+ frac="${EPOCHREALTIME#*[.,]}"
114
+ if [ "$frac" != "$EPOCHREALTIME" ]; then
115
+ frac="${frac}000"
116
+ printf '%s%s\n' "$whole" "${frac:0:3}"
117
+ return
118
+ fi
119
+ fi
120
+ printf '%s000\n' "$(date +%s)"
121
+ }
122
+
123
+ # Appends one line: what a call spent, and what the response said.
124
+ #
125
+ # budget_append <connector> <account> <bucket> <spent> <limit> <remaining> <reset-seconds> <basis>
126
+ #
127
+ # `limit`, `remaining` and `reset` may be empty or `-` where the connector did
128
+ # not report them; `reset` is epoch SECONDS in, epoch MILLISECONDS on disk,
129
+ # matching what `plot-host.sh limit` prints and what `budget.ts` stores.
130
+ #
131
+ # NEVER FAILS ITS CALLER. Recording is bookkeeping beside a host call that has
132
+ # already happened, so a record that cannot be written must not turn a
133
+ # successful call into a failed one. Every failure path returns 0 after writing
134
+ # nothing; a caller that wants to know asks `budget_rate`.
135
+ budget_append() {
136
+ local connector="${1:-}" account="${2:-}" bucket="${3:-}" spent="${4:-1}"
137
+ local limit="${5:-}" remaining="${6:-}" reset="${7:-}" basis="${8:-unknown}"
138
+ local path line reset_ms
139
+
140
+ # A basis this record does not know degrades to `unknown`, the same direction
141
+ # `limitOf` degrades in: a word nobody has seen must never arrive as `actual`,
142
+ # which is the one basis a caller is entitled to trust.
143
+ case "$basis" in
144
+ actual|predicted|unknown) ;;
145
+ *) basis=unknown ;;
146
+ esac
147
+
148
+ # An `unknown` basis carries a null limit whatever was passed. The two would
149
+ # otherwise be able to disagree, and a number tagged *unknown* is the collapse
150
+ # slice 1 exists to refuse.
151
+ if [ "$basis" = unknown ]; then limit=''; fi
152
+
153
+ reset_ms='-'
154
+ if [ -n "$reset" ] && [ "$reset" != '-' ] && [[ "$reset" =~ ^[0-9]+$ ]]; then
155
+ reset_ms="${reset}000"
156
+ fi
157
+
158
+ path="$(budget_path)" || return 0
159
+ line="$(printf 'b1\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \
160
+ "$(budget_clean "$connector")" \
161
+ "$(budget_clean "$account")" \
162
+ "$(budget_clean "$bucket")" \
163
+ "$(budget_now_ms)" \
164
+ "$(budget_num "$spent")" \
165
+ "$(budget_num "$limit")" \
166
+ "$(budget_num "$remaining")" \
167
+ "$reset_ms" \
168
+ "$basis")"
169
+
170
+ # MEASURED IN BYTES, NOT CHARACTERS, and the newline counts. `LC_ALL=C wc -c`
171
+ # counts what the kernel writes; a UTF-8 account name costs more than its
172
+ # length, and the cap is the kernel's guarantee.
173
+ local bytes
174
+ bytes="$(printf '%s\n' "$line" | LC_ALL=C wc -c | tr -d ' ')"
175
+ if [ "${bytes:-0}" -gt "$BUDGET_MAX_LINE_BYTES" ]; then
176
+ # REFUSED, NOT TRUNCATED, and it says so. Shortening the line would write a
177
+ # spend against a key nobody can read back, and a silent refusal would make
178
+ # a systematically over-long key look like an idle account.
179
+ echo "plot-budget: refusing a ${bytes}-byte line over the ${BUDGET_MAX_LINE_BYTES}-byte cap — this call went unrecorded (connector=$connector bucket=$bucket)" >&2
180
+ return 0
181
+ fi
182
+
183
+ mkdir -p "$(dirname "$path")" 2>/dev/null || return 0
184
+ # ONE `printf`, ONE `>>`. The redirection opens with `O_APPEND` and the single
185
+ # write is what the atomicity guarantee is about; two writes could interleave
186
+ # however short each was.
187
+ printf '%s\n' "$line" >>"$path" 2>/dev/null || true
188
+ return 0
189
+ }
190
+
191
+ # How many calls this budget spent inside its window, and how fast.
192
+ #
193
+ # budget_rate <connector> <account> <bucket> [now-ms]
194
+ #
195
+ # Prints one JSON object:
196
+ # {"spent":N,"spanMs":N,"perHour":N|null,"lines":N,"unreadable":N,
197
+ # "limit":N|null,"remaining":N|null,"resetAt":N|null,
198
+ # "basis":"actual|predicted|unknown"}
199
+ #
200
+ # OVER THE CONNECTOR'S WINDOW, NEVER THE WHOLE FILE, and that is the whole
201
+ # reason the window exists. Measured 2026-09-01, one board at 5 s and eleven
202
+ # scripts at 90 s append ~1,160 lines an hour: a rate divided by an
203
+ # ever-growing span approaches zero, which relaxes the cadence forever — the
204
+ # opposite of what the record is for.
205
+ #
206
+ # THE WINDOW IS READ FROM A RESET THAT HAS PASSED, never computed from one still
207
+ # in the future. A reset an hour out, minus an hour, lands on `now` and would
208
+ # discard every line ever written. Same rule as `windowStart` in
209
+ # `packages/domain/src/rules/budget-record.ts`, and a test pins them together.
210
+ BUDGET_FALLBACK_WINDOW_MS=3600000
211
+
212
+ # AN EMPTY BUCKET MEANS EVERY BUCKET, and that is the account-wide question
213
+ # rather than a missing argument. One connector meters several pools
214
+ # independently — GitHub's `core` and `graphql` — and a caller asking *how fast
215
+ # is this account going?* is asking about all of them: an account spends every
216
+ # bucket it has, and a cadence divided by one pool's rate would ignore the
217
+ # traffic on the other.
218
+ #
219
+ # THE VERDICT IS NOT SUMMED, and the aggregate deliberately does not report one.
220
+ # `remaining` and `basis` describe the NEWEST live line across the buckets,
221
+ # which is a reading about whichever pool was spent last — so a caller deciding
222
+ # whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
223
+ # and this is why.
224
+ budget_rate() {
225
+ local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
226
+ local path
227
+ [ -n "$now" ] || now="$(budget_now_ms)"
228
+ path="$(budget_path)" || { echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'; return 0; }
229
+
230
+ # A MISSING FILE IS AN EMPTY RECORD, not a failure — absence is the state of
231
+ # every computer that has not spent yet, and reporting it as broken would make
232
+ # a fresh checkout look faulty.
233
+ if [ ! -f "$path" ]; then
234
+ echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
235
+ return 0
236
+ fi
237
+
238
+ LC_ALL=C awk -v want_c="$connector" -v want_a="$account" -v want_b="$bucket" \
239
+ -v now="$now" -v fallback="$BUDGET_FALLBACK_WINDOW_MS" '
240
+ BEGIN { FS = "\t"; unreadable = 0; n = 0; passed = -1 }
241
+ {
242
+ # A NULL IS THE NORMAL CASE, not an error. The file is appended to by
243
+ # processes that may be killed mid-write, so a torn tail, a blank line and
244
+ # a line from a newer format are all things a reader meets — and every one
245
+ # is skipped rather than thrown on. A reader that failed on one bad line
246
+ # would report the whole account as unreadable, which reads as headroom.
247
+ if ($0 == "") next
248
+ if (NF != 10 || $1 != "b1") { unreadable++; next }
249
+ if ($2 != want_c || $3 != want_a) next
250
+ if (want_b != "" && $4 != want_b) next
251
+ at = $5 + 0
252
+ if ($5 !~ /^-?[0-9]+$/) { unreadable++; next }
253
+ n++
254
+ c_at[n] = at; c_spent[n] = ($6 ~ /^-?[0-9]+$/) ? $6 + 0 : 0
255
+ c_limit[n] = $7; c_rem[n] = $8; c_reset[n] = $9; c_basis[n] = $10
256
+ # The latest reset that has ALREADY happened is where the live window
257
+ # starts: every line older than it describes a bucket that no longer
258
+ # exists. A reset still in the future says only that the window has not
259
+ # closed, and says nothing about when it opened.
260
+ if ($9 ~ /^[0-9]+$/) { r = $9 + 0; if (r <= now && r > passed) passed = r }
261
+ }
262
+ END {
263
+ from = now - fallback
264
+ if (passed >= 0 && passed > from) from = passed
265
+ spent = 0; oldest = -1; newest = -1
266
+ limit = "null"; remaining = "null"; reset = "null"; basis = "unknown"
267
+ for (i = 1; i <= n; i++) {
268
+ if (c_at[i] < from) continue
269
+ spent += c_spent[i]
270
+ if (oldest < 0 || c_at[i] < oldest) oldest = c_at[i]
271
+ # THE NEWEST LIVE LINE THAT CARRIES A READING, not simply the newest
272
+ # line. Most calls cannot report a limit — `gh pr list` is a GraphQL
273
+ # wrapper that exposes no headers — so they record a spend with an
274
+ # `unknown` basis, and one of those arriving after a measurement would
275
+ # erase it. Measured 2026-09-02 against the live host: `limit`
276
+ # harvested `graphql 4391/5000 actual`, one `pr-state` followed, and the
277
+ # bucket then read `remaining: null` — leaving the routing gate
278
+ # permanently unable to see a spent pool, which is the defect this slice
279
+ # exists to remove.
280
+ #
281
+ # A line the RESET has killed is a different case and is already gone:
282
+ # the window filter above dropped it, because it describes a bucket that
283
+ # no longer exists.
284
+ if (c_basis[i] != "unknown" && (newest < 0 || c_at[i] >= newest)) {
285
+ newest = c_at[i]
286
+ limit = (c_limit[i] ~ /^-?[0-9]+$/) ? c_limit[i] : "null"
287
+ remaining = (c_rem[i] ~ /^-?[0-9]+$/) ? c_rem[i] : "null"
288
+ # THE MOMENT THE BUCKET REFILLS, WHICH THE RECORD HAS ALWAYS STORED
289
+ # AND NEVER REPORTED. Field 9 was read for the window boundary above
290
+ # and dropped; a caller reacting to a refusal needs it, and reading it
291
+ # from a second `rate_limit` call would be both metered and wrong —
292
+ # measured 2026-09-01, that endpoint reported 5000 while the headers
293
+ # read 0.
294
+ #
295
+ # THE RESET STILL IN THE FUTURE IS THE ONE A CALLER WAITS FOR, and it
296
+ # is deliberately not the one `windowStart` uses: that boundary needs
297
+ # a reset that has PASSED, because a future one says only that the
298
+ # window has not closed. The two read the same field for opposite
299
+ # halves of the same fact.
300
+ reset = (c_reset[i] ~ /^[0-9]+$/) ? c_reset[i] : "null"
301
+ basis = c_basis[i]
302
+ if (basis != "actual" && basis != "predicted") basis = "unknown"
303
+ # `unknown` IS NOT HEADROOM. A stored number tagged unknown is not a
304
+ # reading, so it is reported as absent rather than as room.
305
+ if (basis == "unknown") { limit = "null"; remaining = "null"; reset = "null" }
306
+ }
307
+ }
308
+ span = (oldest < 0) ? 0 : now - oldest
309
+ if (span < 0) span = 0
310
+ if (span > 0) {
311
+ rate = sprintf("%.2f", spent * fallback / span)
312
+ } else {
313
+ # NO SPAN, NO RATE. One line, or several written in the same
314
+ # millisecond, divides by zero — and an invented rate would be the
315
+ # cadence input this slice exists to make honest.
316
+ rate = "null"
317
+ }
318
+ printf "{\"spent\":%d,\"spanMs\":%d,\"perHour\":%s,\"lines\":%d,\"unreadable\":%d,\"limit\":%s,\"remaining\":%s,\"resetAt\":%s,\"basis\":\"%s\"}\n", \
319
+ spent, span, rate, n, unreadable, limit, remaining, reset, basis
320
+ }
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
+ }
323
+
324
+ # ── The concurrency bound ────────────────────────────────────────────────────
325
+ #
326
+ # HOW MANY CALLS THIS ACCOUNT HAS OPEN AT ONCE, bounded across PROCESSES. The
327
+ # 2026-08-27 incident was eight WORKERS, each running this script once, so the
328
+ # count cannot live in a shell variable and cannot live in the board: a
329
+ # semaphore inside one process bounds nothing that incident measured.
330
+ #
331
+ # THE RECORD CANNOT HOLD IT. `budget.tsv` is append-only with a 512-byte line
332
+ # cap — the two properties that make it lock-free — and an in-flight count needs
333
+ # a DELETE on release. A process killed between claim and release would leave a
334
+ # line nothing removes, and the account would read as permanently full: the cap
335
+ # degrading into a deadlock, which is worse than the 403 it prevents. So the
336
+ # claims sit BESIDE the record, one file per slot, where releasing is an unlink.
337
+ #
338
+ # ONE DIRECTORY PER ACCOUNT, and it is the SAME one `slots-file.ts` uses —
339
+ # `$PLOT_BUDGET_HOME/slots/<account>/<index>`. The board and the workers compete
340
+ # for one cap or they do not compete at all.
341
+ #
342
+ # THE CLAIM IS PUBLISHED BY `ln`, NEVER BY `>`. A redirect creates the NAME
343
+ # before the CONTENT, so a second process can open the empty file, read no claim
344
+ # in it, and reclaim a slot the first is about to write into — measured in
345
+ # `slots-file.test.ts`, six processes taking five slots from a bound of three.
346
+ # `ln` publishes a file that is already complete and fails where the name is
347
+ # taken, so the name and the claim arrive together.
348
+
349
+ # Where one account's claims live. Same override as the record, deliberately:
350
+ # two halves of one budget must not resolve to two places.
351
+ budget_slots_dir() {
352
+ local home="${PLOT_BUDGET_HOME:-}" account="${1:-}"
353
+ if [ -z "$home" ]; then
354
+ [ -n "${HOME:-}" ] || return 1
355
+ home="$HOME/.plot/state"
356
+ fi
357
+ # The account is a string the record does not validate, so it can hold a
358
+ # slash or a dot segment and a path built from it unchecked is a traversal.
359
+ # Same substitution `slots-file.ts` makes, and for the same reason.
360
+ account="$(printf '%s' "$account" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_' | LC_ALL=C sed 's/\.\{2,\}/_/g')"
361
+ [ -n "$account" ] || account='_'
362
+ printf '%s\n' "$home/slots/$account"
363
+ }
364
+
365
+ # How long a claim may stand before it is reclaimable on age alone. Ten minutes,
366
+ # the same figure `CLAIM_STALE_MS` states and for the same reason: liveness is
367
+ # the measurement and this only catches what liveness cannot decide.
368
+ BUDGET_CLAIM_STALE_MS=600000
369
+
370
+ # Is this claim still held? Prints nothing; the exit code is the answer.
371
+ # 0 = held by a live process 1 = reclaimable
372
+ #
373
+ # A PID THE TABLE CANNOT BE ASKED ABOUT KEEPS ITS SLOT. Nothing silently reads
374
+ # unreachable as permission — reclaiming on a failed reading would raise the
375
+ # number of simultaneous callers on the strength of not knowing.
376
+ budget_slot_held() {
377
+ local file="${1:-}" now="${2:-}" pid at
378
+ [ -f "$file" ] || return 1
379
+ pid="$(LC_ALL=C awk -F'\t' 'NR==1 {print $1}' "$file" 2>/dev/null)"
380
+ at="$(LC_ALL=C awk -F'\t' 'NR==1 {print $3}' "$file" 2>/dev/null)"
381
+ # A torn or empty claim names no process holding the slot.
382
+ case "$pid" in ''|*[!0-9]*) return 1 ;; esac
383
+ kill -0 "$pid" 2>/dev/null || return 1
384
+ case "$at" in ''|*[!0-9]*) return 0 ;; esac
385
+ [ -n "$now" ] || now="$(budget_now_ms)"
386
+ [ "$(( now - at ))" -lt "$BUDGET_CLAIM_STALE_MS" ]
387
+ }
388
+
389
+ # Takes one slot, or reports the account busy.
390
+ # budget_slot_acquire <account> <bound>
391
+ # Prints the slot index and exits 0 where one was taken; exits 1 where every
392
+ # slot is held by a live process; exits 2 where the claims could not be managed.
393
+ #
394
+ # BUSY AND UNREADABLE ARE DIFFERENT EXITS, and a caller must not read either as
395
+ # the other — the rule the whole plan states for `unknown`.
396
+ budget_slot_acquire() {
397
+ local account="${1:-}" bound="${2:-}" dir scratch now index path rc=1
398
+ case "$bound" in ''|*[!0-9]*) return 2 ;; esac
399
+ [ "$bound" -ge 1 ] || bound=1
400
+ dir="$(budget_slots_dir "$account")" || return 2
401
+ mkdir -p "$dir" 2>/dev/null || return 2
402
+ now="$(budget_now_ms)"
403
+ scratch="$dir/.$$.$now.tmp"
404
+ # WRITTEN WHOLE, THEN LINKED. See the note above `budget_slots_dir`.
405
+ printf '%s\t-\t%s\n' "$$" "$now" >"$scratch" 2>/dev/null || return 2
406
+ index=0
407
+ while [ "$index" -lt "$bound" ]; do
408
+ path="$dir/$index"
409
+ if ln "$scratch" "$path" 2>/dev/null; then
410
+ printf '%s\n' "$index"; rc=0; break
411
+ fi
412
+ if ! budget_slot_held "$path" "$now"; then
413
+ rm -f "$path" 2>/dev/null
414
+ if ln "$scratch" "$path" 2>/dev/null; then
415
+ printf '%s\n' "$index"; rc=0; break
416
+ fi
417
+ fi
418
+ index=$(( index + 1 ))
419
+ done
420
+ # THE SCRATCH FILE ALWAYS GOES, taken or not. A successful `ln` leaves two
421
+ # names for one inode and the slot keeps the one that matters.
422
+ rm -f "$scratch" 2>/dev/null
423
+ return "$rc"
424
+ }
425
+
426
+ # Gives a slot back. Removes only THIS process's own claim: a slot reclaimed as
427
+ # stale while its owner still ran belongs to somebody else now, and unlinking it
428
+ # on the way out would let the cap be exceeded by one.
429
+ budget_slot_release() {
430
+ local account="${1:-}" index="${2:-}" dir path pid
431
+ case "$index" in ''|*[!0-9]*) return 0 ;; esac
432
+ dir="$(budget_slots_dir "$account")" || return 0
433
+ path="$dir/$index"
434
+ [ -f "$path" ] || return 0
435
+ pid="$(LC_ALL=C awk -F'\t' 'NR==1 {print $1}' "$path" 2>/dev/null)"
436
+ [ "$pid" = "$$" ] || return 0
437
+ rm -f "$path" 2>/dev/null
438
+ return 0
439
+ }
package/plot-config.sh CHANGED
@@ -80,6 +80,15 @@
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
+ # Brief command how /plot-dispatch runs an agent headless to WRITE a
84
+ # missing hand-off brief. The prompt is appended as one
85
+ # argument and asks for `/plot-implement <slug>`, whose
86
+ # step 4 owns brief authorship — this key adds no second
87
+ # brief writer. Absent (or `none`) = the capability is
88
+ # unavailable, never an error: the brief gate refuses as
89
+ # it does today and names `no-brief-command` as the
90
+ # reason, so a project that has never set the key behaves
91
+ # exactly as before.
83
92
  # Plot 2 posture keys (repo-declared ceremony bounds; all optional):
84
93
  # Plan PRs required | never | optional (never = hard gate)
85
94
  # Implementation home this repo | <repo/path list> | none