@plot-pm/board 0.12.0 → 0.13.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-host.sh CHANGED
@@ -8,6 +8,17 @@
8
8
  #
9
9
  # Ops (the ~6 operations plot actually needs):
10
10
  # backend print the resolved backend: github|bitbucket
11
+ # REPORTS WHAT THE REPO DECLARED. An
12
+ # unrecognised `Git host` word is NOT silently
13
+ # defaulted to github — that drove a repo
14
+ # declaring `gitlab` through `gh` until
15
+ # 2026-09-08. A backend this script has no arm
16
+ # for exits 4 and names the word on stderr:
17
+ # exit 4 is "this backend has no answer at
18
+ # all", a configuration a person fixes rather
19
+ # than a transient to retry. Adding a host is
20
+ # an edit to `HOST_DRIVES` and the arms below,
21
+ # and to nothing in `packages/domain`.
11
22
  # default-branch print the repo's default branch name
12
23
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
13
24
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
@@ -366,6 +377,52 @@ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
366
377
  fi
367
378
  }
368
379
 
380
+ # --- the connector names its own repair -------------------------------------
381
+ #
382
+ # THE TEXT COMES FROM THE CONNECTOR THAT FAILED, never from a caller branching
383
+ # on the stack. A refusal saying `gh auth login` is wrong advice on a Bitbucket
384
+ # team, and it is wrong in the direction that costs most: a teammate who has not
385
+ # read this repository runs the command they were given, it does nothing, and
386
+ # the next step is to find a person. This connector already resolved its vendor
387
+ # — `backend` above — so it is the one place that can name a CLI that exists.
388
+ #
389
+ # ONE FUNCTION RATHER THAN A TABLE AT EACH CALL SITE, and it is the connector
390
+ # contract CLAUDE.md already states applied to words instead of budgets: only a
391
+ # connector knows its account, its transport and its login command, so only a
392
+ # connector may name them.
393
+ #
394
+ # A BACKEND THIS DOES NOT KNOW GETS NO ADVICE. Inventing a login command for an
395
+ # unrecognised vendor is the same failure as naming the wrong one, so it says
396
+ # what it does not know rather than guessing — the direction `host_failure_kind`
397
+ # refuses in too.
398
+ #
399
+ # $1 = the backend word, as `backend` reported it.
400
+ host_cli() { # → the CLI this backend drives, or '' where none is known
401
+ case "$1" in
402
+ github) echo "gh" ;;
403
+ bitbucket) echo "bb" ;;
404
+ *) echo "" ;;
405
+ esac
406
+ }
407
+
408
+ # The repair line for a host call that failed, on stderr, in this connector's
409
+ # own words. Prints nothing where the vendor is unknown — see above.
410
+ host_repair() { # $1=backend
411
+ local cli; cli="$(host_cli "$1")"
412
+ if [ -z "$cli" ]; then
413
+ echo " Plot does not know which CLI drives '$1', so it cannot name the fix." >&2
414
+ echo " Set 'Git host' in CLAUDE.md to a host Plot drives (github, bitbucket)." >&2
415
+ return
416
+ fi
417
+ if ! command -v "$cli" >/dev/null 2>&1; then
418
+ echo " '$cli' is not on PATH, and this backend is driven through it." >&2
419
+ echo " Install it, then: $cli auth login" >&2
420
+ return
421
+ fi
422
+ echo " Check the CLI can answer: $cli auth status" >&2
423
+ echo " If it is not logged in: $cli auth login" >&2
424
+ }
425
+
369
426
  # A failed `pr-list`, reported and never swallowed.
370
427
  #
371
428
  # THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
@@ -380,13 +437,28 @@ pr_list_failed() { # $1=stderr text
380
437
  local err="$1"
381
438
  case "$(host_failure_kind "$err")" in
382
439
  secondary)
383
- die6 "pr-list: host refused a burst ${err:-the host refused the request and said nothing}"
440
+ # NO REPAIR NAMED, AND THAT IS THE ANSWER. A burst refusal clears in
441
+ # seconds and nothing is broken, so `$cli auth login` here would send a
442
+ # reader to fix a login that is already fine. The decision is the wait.
443
+ echo "plot-host: pr-list: host refused a burst — ${err:-the host refused the request and said nothing}" >&2
444
+ echo " Nothing is wrong and nothing needs fixing: this limit bounds calls at" >&2
445
+ echo " once, not per hour. Retry shortly, with fewer at a time." >&2
446
+ exit 6
384
447
  ;;
385
448
  throttled)
386
- die5 "pr-list: host throttled — ${err:-the host refused the request and said nothing}"
449
+ echo "plot-host: pr-list: host throttled — ${err:-the host refused the request and said nothing}" >&2
450
+ echo " The window's quota is spent. Wait for the reset the message names," >&2
451
+ echo " or run against an account with quota left. No login will help." >&2
452
+ exit 5
387
453
  ;;
388
454
  esac
389
- die3 "pr-list: ${err:-the host failed the request and said nothing}"
455
+ # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
456
+ # the repair for the first is a login this connector can name — see
457
+ # `host_repair`, which asks the CLI this backend drives rather than assuming
458
+ # one. A failure that is neither costs the reader one `auth status`.
459
+ echo "plot-host: pr-list: ${err:-the host failed the request and said nothing}" >&2
460
+ host_repair "$(backend)"
461
+ exit 3
390
462
  }
391
463
 
392
464
  # Run one `pr-list` host call, or die reporting which failure it was.
@@ -1225,7 +1297,10 @@ jira_require_config() {
1225
1297
  die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
1226
1298
  fi
1227
1299
  if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
1228
- die3 "Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox"
1300
+ echo "plot-host: Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox" >&2
1301
+ echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
1302
+ echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>." >&2
1303
+ exit 3
1229
1304
  fi
1230
1305
  }
1231
1306
 
@@ -1348,21 +1423,64 @@ pr_list_report_truncation() {
1348
1423
  echo "plot-host: $be pr-list state=$state possibly truncated ($count rows, requested limit $limit unprovable) — a join against this page may read older branches as 'no PR' (#333)" >&2
1349
1424
  }
1350
1425
 
1351
- backend() {
1426
+ # The backends this script has an arm for, which is what "drivable" means here.
1427
+ #
1428
+ # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
1429
+ # host means teaching the ops below its CLI; this array is the record of which
1430
+ # have been taught, and it sits beside the arms it describes. `packages/domain`
1431
+ # holds no copy — `HostBackend` is any string, and `host-shell.ts` passes
1432
+ # through whatever this prints — so a third host is an edit here and nowhere in
1433
+ # the domain.
1434
+ HOST_DRIVES="github bitbucket"
1435
+
1436
+ # Is this a backend the ops below can actually drive?
1437
+ host_drivable() { # $1=backend word
1438
+ case " $HOST_DRIVES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
1439
+ }
1440
+
1441
+ # The backend this repository declared, in the word it used.
1442
+ #
1443
+ # REPORTS WHAT IT WAS TOLD, AND DEFAULTS ONLY WHERE NOTHING WAS SAID. A repo
1444
+ # declaring `Git host: gitlab` was answered `github` until 2026-09-08 — the
1445
+ # unrecognised word was discarded and the default returned in its place, so a
1446
+ # GitLab team was driven through `gh` and every refusal named a host they had
1447
+ # not configured. An absent key still defaults to github, which is the same
1448
+ # answer for the same reason: nothing was said, so nothing was discarded.
1449
+ #
1450
+ # `bb` is an ALIAS rather than an unknown word, and it normalises to bitbucket.
1451
+ backend_declared() {
1352
1452
  if [ -n "${PLOT_HOST:-}" ]; then
1353
- case "$PLOT_HOST" in
1354
- github|bitbucket) echo "$PLOT_HOST"; return ;;
1355
- *) die "unknown PLOT_HOST '$PLOT_HOST' (github|bitbucket)" ;;
1356
- esac
1453
+ printf '%s\n' "$PLOT_HOST" | tr '[:upper:]' '[:lower:]'
1454
+ return
1357
1455
  fi
1358
1456
  local v
1359
1457
  v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
1360
1458
  case "$v" in
1361
- bitbucket|bb) echo "bitbucket" ;;
1362
- *) echo "github" ;;
1459
+ bb) echo "bitbucket" ;;
1460
+ "") echo "github" ;;
1461
+ *) printf '%s\n' "$v" ;;
1363
1462
  esac
1364
1463
  }
1365
1464
 
1465
+ # The resolved backend, refused where this script has no arm for it.
1466
+ #
1467
+ # THE REFUSAL IS THIS LAYER'S, AND IT NAMES THE WORD. Every op below dispatches
1468
+ # on this answer, so a word with no arm must stop here rather than fall into
1469
+ # whichever branch happens to be last — that is how `gitlab` became a Bitbucket
1470
+ # call. Exit 4 says the question cannot be asked of this backend AT ALL, which
1471
+ # is what an unknown host is: not a broken call to retry, but a configuration a
1472
+ # person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
1473
+ # sentence below for the name.
1474
+ backend() {
1475
+ local v
1476
+ v="$(backend_declared)" || return 1
1477
+ if ! host_drivable "$v"; then
1478
+ echo "plot-host: cannot drive '$v' — this script drives ${HOST_DRIVES// /, }; set the 'Git host' key in CLAUDE.md (or \$PLOT_HOST) to one of them" >&2
1479
+ return 4
1480
+ fi
1481
+ printf '%s\n' "$v"
1482
+ }
1483
+
1366
1484
 
1367
1485
  # --- the connector counts what it spends -----------------------------------
1368
1486
  #
@@ -1802,7 +1920,11 @@ jen() {
1802
1920
 
1803
1921
  op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
1804
1922
  shift
1805
- be="$(backend)" || exit 1
1923
+ # THE BACKEND'S REFUSAL IS PASSED THROUGH, NOT FLATTENED. `backend` exits 4 for
1924
+ # a host it has no arm for, and exit 4 is the contract's "this backend has no
1925
+ # answer at all" — the one code every caller reads as permanent rather than
1926
+ # transient. Collapsing it to 1 here would tell a GitLab repo to retry forever.
1927
+ be="$(backend)" || exit $?
1806
1928
 
1807
1929
  # EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
1808
1930
  # the op runs and its answer is read from `$route` by whichever arm needs it —
@@ -2161,7 +2283,11 @@ case "$op" in
2161
2283
  jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
2162
2284
  [ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
2163
2285
  if [ -z "$jen_instance" ]; then
2164
- die3 "CI is jenkins but no Jenkins instance is configured (set a 'Jenkins instance' key)"
2286
+ echo "plot-host: CI is jenkins but no Jenkins instance is configured" >&2
2287
+ echo " Add a 'Jenkins instance' key to the ## Plot Config section of CLAUDE.md," >&2
2288
+ echo " naming the instance \`jen\` knows, or set JENKINS_INSTANCE." >&2
2289
+ echo " Or drop the 'CI: jenkins' key to read build status from the git host." >&2
2290
+ exit 3
2165
2291
  fi
2166
2292
  jen_payload=$(jenkins_build_map "$jen_instance")
2167
2293
  jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
@@ -754,3 +754,97 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
754
754
  # mistake in the other direction.
755
755
  printf 'ended\t%s\t' "$pid"
756
756
  }
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # THE SAME READINGS, HANDED OUT RATHER THAN DECIDED
760
+ # ---------------------------------------------------------------------------
761
+ #
762
+ # `plot_worker_state` above gathers six facts and turns them into a word. So
763
+ # does `rules/agent-state.ts`, and `docs/shell-and-domain.md` says why both
764
+ # exist: this function is sourced inside per-branch loops and by the agent's own
765
+ # loop, where a `node` hop is 39 ms every caller pays on every pass, while the
766
+ # board and the supervisor are already in node and pay nothing.
767
+ #
768
+ # WHAT THE PAIR NEEDS IS THE READINGS, NOT THE WORD. A caller handed `finished`
769
+ # can only compare two strings; a caller handed the six facts can ask the domain
770
+ # rule and compare its answer to this file's. That is what the corpus test does,
771
+ # and it is why this function exists at all.
772
+ #
773
+ # ONE GATHERING, TWO CONSUMERS. Every fact below is read the way
774
+ # `plot_worker_state` reads it — the manifest first and the worktree file as the
775
+ # fallback, `plot_pid_is_current` for staleness, the same `PLOT-BLOCKED*` glob
776
+ # and the same dirty filter. A second gathering that drifted would make the
777
+ # corpus test compare this file against itself and pass while production broke.
778
+ #
779
+ # Prints one TAB-separated line, six fields:
780
+ #
781
+ # worktree_here pid_recorded liveness exit blocked dirty unpushed
782
+ #
783
+ # `liveness` is `live`, `stale` or `dead`; `exit` is the code as read, empty for
784
+ # an unreadable record and the literal `-` for an absent one, because an empty
785
+ # field cannot say which of the two it is and the rule answers them alike only
786
+ # because it was told they differ. The PR fact is NOT here: it comes from the
787
+ # caller, exactly as it does for `plot_worker_state`.
788
+ plot_worker_readings() { # $1=worktree → "here\tpid\tliveness\texit\tblocked\tdirty\tunpushed"
789
+ local wt="$1" pid="" started_at="" manifest_data="" manifest=""
790
+ local here=1 pid_recorded=0 liveness=dead exit_field='-' blocked=0 dirty=0 ahead=""
791
+
792
+ # NO WORKTREE IS ANSWERED FIRST, and it is a question about the worktree LIST
793
+ # rather than about anything inside one — the same split `worker_of` makes in
794
+ # `plot-fleet-scan.sh`, where `elsewhere` is decided before this file is
795
+ # reached. A caller iterating worktrees it found never sees this arm.
796
+ if [ -z "$wt" ] || [ ! -d "$wt" ]; then
797
+ printf '0\t0\tdead\t-\t0\t0\t'
798
+ return
799
+ fi
800
+
801
+ # THE MANIFEST IS PRIMARY, as above: it carries `pid` and `startedAt`
802
+ # together, and `startedAt` is what tells a reused pid from the real worker.
803
+ if manifest=$(plot_manifest_for_worktree "$wt" 2>/dev/null) && [ -n "$manifest" ]; then
804
+ if manifest_data=$(plot_read_manifest_pid "$manifest") && [ -n "$manifest_data" ]; then
805
+ pid=$(printf '%s' "$manifest_data" | cut -f1)
806
+ started_at=$(printf '%s' "$manifest_data" | cut -f2)
807
+ fi
808
+ fi
809
+ if [ -z "$pid" ] && [ -f "$wt/.plot-worker.pid" ]; then
810
+ pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null | tr -d ' \n')
811
+ fi
812
+
813
+ # A pid of 0 and any non-numeric junk are NOT pids. `kill -0 0` signals the
814
+ # whole process group and succeeds, so a zero read as live reports `running`
815
+ # forever — rejected here exactly as `plot_worker_state` rejects it.
816
+ case "$pid" in
817
+ ''|0|*[!0-9]*) pid_recorded=0 ;;
818
+ *) pid_recorded=1 ;;
819
+ esac
820
+
821
+ if [ "$pid_recorded" = 1 ]; then
822
+ if kill -0 "$pid" 2>/dev/null; then
823
+ # A recorded start time closes the pid-reuse window. Without one the old
824
+ # behaviour applies and `kill -0` alone decides, which keeps an
825
+ # uncheckable pid honest rather than pessimistic.
826
+ if [ -n "$started_at" ] && ! plot_pid_is_current "$pid" "$started_at"; then
827
+ liveness=stale
828
+ else
829
+ liveness=live
830
+ fi
831
+ fi
832
+ fi
833
+
834
+ # THE EXIT RECORD, distinguishing absent from unreadable. `plot_worker_state`
835
+ # reaches `ended` for both, but by different routes, and a reading that
836
+ # collapsed them would hide which one a desk is in from anybody comparing.
837
+ if [ -f "$wt/.plot-worker.exit" ]; then
838
+ exit_field=$(cat "$wt/.plot-worker.exit" 2>/dev/null | tr -d ' \n')
839
+ fi
840
+
841
+ plot_worker_blocked "$wt" && blocked=1 || blocked=0
842
+ [ -n "$(plot_worker_dirty "$wt")" ] && dirty=1 || dirty=0
843
+ # UNPUSHED IS A REF QUESTION asked THROUGH the worktree, and an unreadable
844
+ # count stays EMPTY — `null` is not `false`, and a branch with no upstream
845
+ # cannot be asked at all.
846
+ ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || ahead=""
847
+
848
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
849
+ "$here" "$pid_recorded" "$liveness" "$exit_field" "$blocked" "$dirty" "$ahead"
850
+ }