@plot-pm/board 0.15.0 → 0.16.1

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.
@@ -133,9 +133,9 @@
133
133
  # lifecycle state, verbatim from plot-plan-meta.sh, and the half of a
134
134
  # row's phase git cannot answer. Which column a row reads is composed
135
135
  # from it AND the branch state one layer up; this script decides nothing.
136
- # The plan set also includes plans delivered inside a rolling 24 h
137
- # window (see "the last day of finished work"), so work does not
138
- # disappear at the moment it becomes finished.
136
+ # The plan set also includes every plan at `Delivered`, until it is
137
+ # `Released` (see "the release scope"), so DONE names what the next
138
+ # release ships.
139
139
  # Plans are enumerated from REFS (`git ls-tree`/`git show`), NOT from
140
140
  # the working tree — so the list describes committed state and does not
141
141
  # change while rebases and worker commits rewrite the checkout
@@ -253,6 +253,8 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
253
253
  # plot-dispatch.sh so a worker has ONE state, not one per reader.
254
254
  # shellcheck source=plot-worker-state.sh
255
255
  . "$script_dir/plot-worker-state.sh"
256
+ # Every temp path this scan creates, and its only EXIT/INT/TERM traps.
257
+ . "$script_dir/plot-tmp.sh"
256
258
  cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
257
259
 
258
260
  do_fetch=1
@@ -573,14 +575,15 @@ fi
573
575
  # outlives the scan that fetched it — a stale `merged` read from a previous run
574
576
  # is exactly the fabricated verdict the failure direction above forbids.
575
577
  #
576
- # Cleanup is trapped rather than trailing: the script exits early in several
578
+ # Cleanup is registered rather than trailing: the script exits early in several
577
579
  # places (--next with nothing to start, no active plans), and a temp directory
578
- # left behind on those paths would accumulate one per poll.
580
+ # left behind on those paths would accumulate one per poll. It goes through
581
+ # `plot-tmp.sh`'s one registry: this cache had its own EXIT trap, and the
582
+ # `REF_TMP` trap below replaced it, so every scan left one `tmp.*` directory of
583
+ # ~955 files behind (measured 2026-09-30).
579
584
  HOST_STATE_CACHE=""
580
585
  if [ "$HOST_LOOKUP_OK" = 1 ]; then
581
- HOST_STATE_CACHE=$(mktemp -d 2>/dev/null) || HOST_STATE_CACHE=""
582
- [ -n "$HOST_STATE_CACHE" ] \
583
- && trap 'rm -rf "$HOST_STATE_CACHE" 2>/dev/null || true' EXIT INT TERM
586
+ plot_tmpdir HOST_STATE_CACHE fleet-host-state 2>/dev/null || HOST_STATE_CACHE=""
584
587
  fi
585
588
 
586
589
  # The cache key. Shared by the join and by `host_pr_state`, because the two
@@ -2245,37 +2248,15 @@ pr_ready() {
2245
2248
  }
2246
2249
 
2247
2250
  # ---------------------------------------------------------------------------
2248
- # Recently delivered plans: the last day of finished work
2251
+ # Delivered plans: the release scope
2249
2252
  # ---------------------------------------------------------------------------
2250
2253
  #
2251
- # The pulse read `active/` only, so a plan left the view the INSTANT it was
2252
- # delivered — taking every branch with it. Measured on this repo: five plans
2253
- # delivered in one day named eight branches between them, and DONE showed one,
2254
- # because delivery and merge are minutes apart and only whichever branch
2255
- # happened to sit in the gap survived. A group that is full by accident is
2256
- # worse than one that is empty by rule.
2257
- #
2258
- # A ROLLING 24 HOURS, not the calendar day. Literally "delivered today" is
2259
- # easier to explain and wrong at exactly the wrong moment: a plan delivered at
2260
- # 23:50 vanishes ten minutes later, mid-session, while the branches it names
2261
- # are still on screen. 24 is also the one freshness bound this repo already
2262
- # uses (`Claim stale after`), so it is one unit to learn rather than two.
2263
- #
2264
- # THE WINDOW FILTERS BEFORE THE PARSE. Measured: ~57 ms per plan through
2265
- # plot-plan-meta.sh against a scan that already runs 500–1050 ms, so parsing
2266
- # fourteen delivered plans to discard thirteen would roughly double the pulse —
2267
- # and that cost grows with the archive, which only ever gets larger, while the
2268
- # answer stays the size of a day's work. So the cheap signal comes first (the
2269
- # delivered symlink's own mtime) and only the candidates it admits are parsed.
2270
- #
2271
- # The pre-filter may OVER-ADMIT AND PAY A PARSE; it may never exclude. A
2272
- # checkout can freshen an old file, so the `Delivered:` record keeps the last
2273
- # word — but nothing mtime rules out could have been delivered inside the
2274
- # window. On a fresh clone or a CI worktree every file shares one checkout
2275
- # timestamp and ALL of them are admitted: correct, merely slower, once. Reaching
2276
- # for `git log` per plan to avoid that would spend a git call to save a parse.
2277
- DELIVERED_WINDOW_HOURS=$(cfg "Claim stale after" "24")
2278
- case "$DELIVERED_WINDOW_HOURS" in (*[!0-9]*|'') DELIVERED_WINDOW_HOURS=24 ;; esac
2254
+ # A plan at `Delivered` stays in the pulse until it is `Released`, whatever its
2255
+ # age. DONE holds the release scope (`done-holds-what-is-still-yours`): every
2256
+ # plan whose work has landed and whose version has not shipped. A 24-hour window
2257
+ # on the `Delivered:` record bounded this until 2026-10-01, when 25 plans awaited
2258
+ # 2.22.0 and DONE showed 10. Every plan file is parsed in one pass anyway, so the
2259
+ # window bought no parse time; it only hid work the next release ships.
2279
2260
 
2280
2261
 
2281
2262
  # ---------------------------------------------------------------------------
@@ -2441,52 +2422,7 @@ changed_ago_of() { # $1=branch → "<seconds since>\t<epoch of>" for the newest
2441
2422
  if [ "$newest" -gt "$now" ]; then printf '0\t%s' "$newest"; else printf '%s\t%s' "$((now - newest))" "$newest"; fi
2442
2423
  }
2443
2424
 
2444
- # A plan whose delivered symlink was touched inside the window. `find -newermt`
2445
- # is not portable to every BSD find in the wild, so the cutoff is computed and
2446
- # compared with `stat` — one stat per file, no parse.
2447
- delivered_candidates() {
2448
- local cutoff now link mtime
2449
- now=$(date +%s)
2450
- cutoff=$((now - DELIVERED_WINDOW_HOURS * 3600))
2451
- for link in "$DELIVERED_DIR"*.md; do
2452
- [ -e "$link" ] || continue
2453
- # `stat` follows the symlink, which is what we want: the TARGET is the plan,
2454
- # and a plan edited after delivery must still admit. An unreadable time
2455
- # ADMITS rather than excludes — the pre-filter may only over-admit, and the
2456
- # `Delivered:` record has the last word either way.
2457
- mtime=$(file_mtime "$link") || { printf '%s\n' "$link"; continue; }
2458
- [ "$mtime" -ge "$cutoff" ] && printf '%s\n' "$link"
2459
- done
2460
- }
2461
2425
 
2462
- # Does this plan's `Delivered:` record fall inside the window? The RECORD
2463
- # decides — mtime only chose who got asked.
2464
- #
2465
- # "No date, no row." A delivered plan with an empty record does not appear at
2466
- # all: no date means no membership in any window, the same rule the waiting age
2467
- # already follows. Showing it always would create the one row that can never
2468
- # age out of DONE, and the missing record is a bookkeeping fault
2469
- # plot-reconcile-scan.sh exists to report — a view that quietly compensates
2470
- # for it makes the fault harder to see.
2471
- #
2472
- # A BARE DATE IS ANCHORED AT THE END OF ITS DAY, not at midnight, and this is
2473
- # the one detail that makes "rolling, not the calendar day" true rather than
2474
- # merely stated. Every `Delivered:` record in this repo is a bare date, which
2475
- # names no time — so anchoring at 00:00 measures from up to a day BEFORE the
2476
- # delivery, and the window collapses back into exactly the calendar boundary
2477
- # the rolling window exists to avoid: a plan delivered at 23:50 would be an
2478
- # hour from expiry the moment it was written, and gone ten minutes later
2479
- # mid-session while the branches it names are still on screen.
2480
- #
2481
- # Anchoring at 23:59:59 over-admits by at most the length of the delivery day.
2482
- # That is the same direction the mtime pre-filter is allowed to err in, and for
2483
- # the same reason: showing a finished plan slightly too long costs a row, while
2484
- # dropping one mid-session costs the reader the work they were looking at. A
2485
- # record that DOES carry a time is honoured exactly, so the imprecision belongs
2486
- # to the record rather than to the rule.
2487
- # The rule itself now runs inside the ONE estate parse below (`in_window`),
2488
- # because asking it per plan meant an interpreter per plan. What it decides is
2489
- # unchanged; only the number of processes that decide it is.
2490
2426
 
2491
2427
  # ---------------------------------------------------------------------------
2492
2428
  # Plan enumeration: from the REF, not from the tree
@@ -2577,15 +2513,15 @@ ref_ls() { # $1=dir → newline-separated paths under it in origin/$MAIN
2577
2513
  # `ref_plan_file` is called as `$(ref_plan_file ...)`, which runs it in a
2578
2514
  # SUBSHELL. A lazy `[ -z "$REF_TMP" ] && REF_TMP=$(mktemp -d)` inside it
2579
2515
  # assigns in the child and the parent never sees it — so every call made a
2580
- # fresh directory, the parent's variable stayed empty, and the EXIT trap
2581
- # cleaned nothing. Measured while writing this: three plans, three temp dirs,
2516
+ # fresh directory, the parent's variable stayed empty, and the exit cleanup
2517
+ # removed nothing. Measured while writing this: three plans, three temp dirs,
2582
2518
  # none removed. The lifetime is owned out here, where the trap can see it.
2583
2519
  REF_TMP=""
2584
2520
  if [ "$PLAN_SOURCE" = "ref" ]; then
2585
- REF_TMP=$(mktemp -d "${TMPDIR:-/tmp}/plot-fleet-ref.XXXXXX") || REF_TMP=""
2586
2521
  # The scan is read-only and short-lived, and the board polls it every 5 s —
2587
- # a directory that outlives the run would accumulate one per poll.
2588
- [ -n "$REF_TMP" ] && trap 'rm -rf "$REF_TMP"' EXIT INT TERM
2522
+ # a directory that outlives the run would accumulate one per poll, so the
2523
+ # helper removes it at exit.
2524
+ plot_tmpdir REF_TMP fleet-ref || REF_TMP=""
2589
2525
  # No temp dir means no way to hand the parser a file, so the ref path cannot
2590
2526
  # work. Falling back to the checkout is the honest answer, and it announces
2591
2527
  # itself through `plan_source` exactly like an unreadable ref.
@@ -2890,7 +2826,7 @@ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2890
2826
  #
2891
2827
  # `record` types, one per line, all tab-separated and all prefixed by the plan
2892
2828
  # file they describe:
2893
- # P <file> <phase> <delivered_in_window> one per parsed file
2829
+ # P <file> <phase> one per parsed file
2894
2830
  # W <file> <wave-idx> <branch> <deferred> <why> <wave-name> <claim>
2895
2831
  #
2896
2832
  # NO ASSOCIATIVE ARRAYS. `/bin/bash` on macOS is 3.2 and this script uses no
@@ -2908,7 +2844,7 @@ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2908
2844
  # gave when it failed.
2909
2845
  plan_meta_files=()
2910
2846
  plan_meta_phases=()
2911
- plan_meta_inwindow=()
2847
+ plan_meta_types=()
2912
2848
  plan_meta_waves=()
2913
2849
 
2914
2850
  # Parses every plan file given, filling the four arrays above. Called ONCE.
@@ -2919,33 +2855,6 @@ parse_plan_estate() { # $@=files to parse
2919
2855
  | python3 -c '
2920
2856
  import json, re, sys, time
2921
2857
 
2922
- window = float(sys.argv[1]) * 3600
2923
- now = time.time()
2924
-
2925
- def in_window(raw):
2926
- """The `Delivered:` record against the rolling window — the same rule the
2927
- per-plan test applied, moved into the one pass. A record whose date does
2928
- not parse is dropped rather than coerced: Date-style leniency would turn a
2929
- typo into a confident answer. A record with no time anchors at 23:59:59,
2930
- so a plan delivered at 23:50 is not an hour from expiry the moment it is
2931
- written. A FUTURE record is INSIDE (negative age), because hiding a live
2932
- plan for a mistyped year costs more than showing one."""
2933
- raw = (raw or "").strip()
2934
- if not raw:
2935
- return False
2936
- m = re.match(r"(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?", raw)
2937
- if not m:
2938
- return False
2939
- y, mo, dy, hh, mi = m.groups()
2940
- timed = hh is not None
2941
- try:
2942
- at = time.mktime((int(y), int(mo), int(dy),
2943
- int(hh) if timed else 23, int(mi) if timed else 59,
2944
- 0 if timed else 59, 0, 0, -1))
2945
- except (ValueError, OverflowError):
2946
- return False
2947
- return (now - at) <= window
2948
-
2949
2858
  def clean(s):
2950
2859
  return str(s).replace("\t", " ").replace("\n", " ")
2951
2860
 
@@ -2963,8 +2872,7 @@ for line in sys.stdin:
2963
2872
  f = d.get("file")
2964
2873
  if not f:
2965
2874
  continue
2966
- print("\t".join(["P", clean(f), clean(d.get("phase", "")),
2967
- "1" if in_window(d.get("delivered_raw")) else "0"]))
2875
+ print("\t".join(["P", clean(f), clean(d.get("phase", "")), clean(d.get("type", ""))]))
2968
2876
  for i, w in enumerate(d.get("waves", []) or []):
2969
2877
  name = w.get("name")
2970
2878
  for b in w.get("branches", []) or []:
@@ -2991,7 +2899,7 @@ for line in sys.stdin:
2991
2899
  (b.get("deferred_reason") or "-"),
2992
2900
  (b.get("waits_on") or "-"),
2993
2901
  name or "-", b.get("claimed") or "-"]))
2994
- ' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
2902
+ ' 2>/dev/null) || records=""
2995
2903
 
2996
2904
  local kind file rest
2997
2905
  while IFS=$'\t' read -r kind file rest; do
@@ -2999,9 +2907,12 @@ for line in sys.stdin:
2999
2907
  case "$kind" in
3000
2908
  P)
3001
2909
  plan_meta_files+=("$file")
3002
- # `rest` is "<phase>\t<inwindow>"; both are single tokens with no tabs.
3003
- plan_meta_phases+=("${rest%% *}")
3004
- plan_meta_inwindow+=("${rest##* }")
2910
+ # `rest` is "<phase>\t<type>", two tokens with no tabs inside either.
2911
+ plan_meta_phases+=("${rest%%$'\t'*}")
2912
+ case "$rest" in
2913
+ *$'\t'*) plan_meta_types+=("${rest#*$'\t'}") ;;
2914
+ *) plan_meta_types+=("") ;;
2915
+ esac
3005
2916
  plan_meta_waves+=("")
3006
2917
  ;;
3007
2918
  W)
@@ -3125,8 +3036,8 @@ else
3125
3036
  # was buying less than it appeared to: it keyed off the `$DELIVERED_DIR`
3126
3037
  # symlink's mtime, and a fresh checkout stamps every symlink at once — 56 of
3127
3038
  # 56 delivered links admitted here, so the parse it was meant to avoid was
3128
- # already being paid in full. `delivered_in_window` (the `Delivered:` record)
3129
- # was always the filter that actually decided, and the pre-filter's own
3039
+ # already being paid in full. The `Delivered:` record's window was the filter
3040
+ # that actually decided until 2026-10-01, when the phase alone took over, and the pre-filter's own
3130
3041
  # contract was that it may only ever OVER-admit. Removing it takes that
3131
3042
  # contract to its limit — strictly more correct, and on this repo not even
3132
3043
  # more expensive.
@@ -3374,7 +3285,7 @@ EOF
3374
3285
  echo "$total $n"
3375
3286
  }
3376
3287
 
3377
- # WHAT WAS READ OF ONE BRANCH — ten tab-separated fields, and no decision.
3288
+ # WHAT WAS READ OF ONE BRANCH — eleven tab-separated fields, and no decision.
3378
3289
  #
3379
3290
  # `branch_state()` UNTIL THIS SLICE, and every line of git archaeology below is
3380
3291
  # its own, unchanged. What went is the `if` chain that merged these readings
@@ -3391,10 +3302,11 @@ EOF
3391
3302
  # run of tabs collapses into one separator under `read`, so no field is ever
3392
3303
  # empty. Nothing here is optional, so nothing can shift.
3393
3304
  #
3394
- # EIGHT FIELDS, NOT TEN. The two the plan states — the prerequisite's name and
3395
- # what the host said about it — are appended by the caller, because reading the
3396
- # second costs a host round trip and the scan spends it only where it could
3305
+ # EIGHT FIELDS, NOT ELEVEN. The two the plan states — the prerequisite's name
3306
+ # and what the host said about it — are appended by the caller, because reading
3307
+ # the second costs a host round trip and the scan spends it only where it could
3397
3308
  # change the answer. The rule reports which states those are; see the caller.
3309
+ # The caller appends the PR list's completeness last, once per run.
3398
3310
  #
3399
3311
  # THE DEFAULT BRANCH'S TIP IS READ ONCE PER RUN, not once per branch. It does
3400
3312
  # not move while the scan runs — every fact below is derived from the ref batch
@@ -3743,26 +3655,19 @@ for plan in "${plans[@]}"; do
3743
3655
  # is a judgment that belongs one layer up (Manifesto Principle 3).
3744
3656
  plan_phase="${plan_meta_phases[$meta_i]}"
3745
3657
 
3746
- # The delivered window, applied to the plans the PHASE put in the terminal
3747
- # group. Enumeration grouped them; the `Delivered:` RECORD decides which of
3748
- # them still appears.
3749
- #
3750
- # THE TEST IS THE PHASE, not the path. It read `case "$plan" in "$DELIVERED_DIR"*)`
3751
- # — the directory the link sat in — and that made "which group is this plan
3752
- # in" a fact about a symlink while "what phase is it" was a fact about the
3753
- # file. The old comment here noted that an active plan carrying
3754
- # `Phase: Delivered` was drift the window must not hide; under the phase rule
3755
- # that drift cannot be constructed, because there is no second place for the
3756
- # answer to live. One source, so nothing to disagree.
3757
- #
3758
- # Two exits, and both matter:
3759
- # * the record's date has aged out of the window — ordinary expiry;
3760
- # * there is NO record — "no date, no row". `reconcile-scan-accuracy.md` is
3761
- # the live example; showing it would create the one row that can never
3762
- # age out of DONE.
3763
- # Both leave before a single git call is spent on the plan's branches.
3764
- if is_terminal_phase "$plan_phase"; then
3765
- [ "${plan_meta_inwindow[$meta_i]}" = "1" ] || continue
3658
+ # THE RELEASE SCOPE, decided by the PHASE alone. A terminal plan stays in the
3659
+ # pulse only while it is `delivered`: its work has landed and its version has
3660
+ # not shipped, which is what DONE holds. `released`, `rejected` and
3661
+ # `superseded` leave, whatever their age, and leave before a single git call
3662
+ # is spent on their branches. No date bounds it, so a plan delivered weeks
3663
+ # before a release still names the release's contents.
3664
+ if is_terminal_phase "$plan_phase" && [ "$plan_phase" != "delivered" ]; then
3665
+ continue
3666
+ fi
3667
+ # A docs or infra plan is live when it merges: /plot-release never records
3668
+ # `Released` for one, so `delivered` is its last phase and it leaves here.
3669
+ if [ "$plan_phase" = "delivered" ]; then
3670
+ case "${plan_meta_types[$meta_i]}" in docs|infra) continue ;; esac
3766
3671
  fi
3767
3672
 
3768
3673
  n_plans=$((n_plans + 1))
@@ -3860,12 +3765,17 @@ for plan in "${plans[@]}"; do
3860
3765
  # deciding it — see pass 1c.
3861
3766
  readings=""
3862
3767
  order=""
3768
+ # THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
3769
+ # written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
3770
+ # main as `open` only when it is true; a capped list may omit a merged PR.
3771
+ list_complete=false
3772
+ [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-complete" ] && list_complete=true
3863
3773
  while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3864
3774
  [ -n "$br" ] || continue
3865
3775
  # "-" is the absent marker the shim writes, for the tab-collapse reason
3866
3776
  # above. Normalized here so everything downstream tests emptiness.
3867
3777
  [ "$waits" = "-" ] && waits=""
3868
- readings+="$(branch_readings "$br" "$deferred") ${waits:--} ?"$'\n'
3778
+ readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
3869
3779
  order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
3870
3780
  done <<< "$wave_lines"
3871
3781
 
@@ -3920,7 +3830,8 @@ for plan in "${plans[@]}"; do
3920
3830
  # list may legitimately omit: its plan may be delivered and its ref gone.
3921
3831
  # `host_pr_state`'s run cache keeps this at one call per prerequisite per
3922
3832
  # run, never one per pass.
3923
- refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br")"$'\n'
3833
+ # Field 10 is replaced; field 11 is carried.
3834
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br") $(printf '%s' "$rd_line" | cut -f11)"$'\n'
3924
3835
  else
3925
3836
  refill+="$rd_line"$'\n'
3926
3837
  fi