@plot-pm/board 0.14.2 → 0.14.3

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.2",
3
+ "version": "0.14.3",
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",
package/plot-deliver.sh CHANGED
@@ -425,9 +425,15 @@ decide_transition() { # $1=file → prints "<Phase>\t<record>\t<write|already>"
425
425
  # carrying BOTH front matter and a `## Status` block was delivered in a project
426
426
  # repo: `flip_phase` wrote `Delivered` into the block, `mv` landed it, the
427
427
  # summary said `phase=flipped`, and `plot-plan-meta.sh` went on answering
428
- # `approved` — because it prefers front matter wherever it exists and reads the
429
- # block only in the `else if` below. The write took effect on bytes nobody
430
- # reads.
428
+ # `approved` — because it preferred front matter wherever it existed. The write
429
+ # took effect on bytes nobody reads.
430
+ #
431
+ # THAT PRECEDENCE INVERTED IN #933, so the two-record plan now delivers rather
432
+ # than refusing here: the parser reads the `## Status` block, which is the field
433
+ # every lifecycle script writes. This gate is unchanged and is not softened —
434
+ # its condition simply stops holding for that shape. It still fires on a scratch
435
+ # copy the parser cannot read, and it still asks the parser rather than trusting
436
+ # that awk changed a line.
431
437
  #
432
438
  # `flip_phase`'s awk matches only inside `section == "status"`. That one guard
433
439
  # IS the defect: on a front-matter plan it edits the block and leaves the front
@@ -468,11 +474,11 @@ phase_would_read() { # $1=scratch file $2=expected phase (lowercase) → 0 agree
468
474
  # holding two records of one fact is the thing to fix — and which format ought
469
475
  # to win is a decision this gate deliberately leaves to a person.
470
476
  echo "plot-deliver: $rel — wrote phase '$want', but the parser still reads '$got'." >&2
471
- echo " The plan states its phase in TWO places and they disagree: the write" >&2
472
- echo " landed in the '## Status' block while front matter takes precedence," >&2
473
- echo " so the delivery would have reported a success it did not achieve." >&2
474
- echo " Nothing was written — the plan is unchanged. Remove one of the two" >&2
475
- echo " records (front matter, or the '## Status' block) and re-run." >&2
477
+ echo " The write landed and the parser reads something else, so the delivery" >&2
478
+ echo " would have reported a success it did not achieve." >&2
479
+ echo " Nothing was written — the plan is unchanged. Check what the plan says" >&2
480
+ echo " its phase is, and where: a plan stating it in two places reports the" >&2
481
+ echo " '## Status' block, which is the field every lifecycle script writes." >&2
476
482
  echo " See what the parser reads: $script_dir/plot-plan-meta.sh $rel" >&2
477
483
  return 1
478
484
  }
package/plot-dispatch.sh CHANGED
@@ -1904,62 +1904,70 @@ EOF
1904
1904
  esac
1905
1905
  fi
1906
1906
 
1907
- # THE COUNT IS THE RULE'S, and the rule is `packages/domain/src/rules/
1908
- # fleet-size.ts` — imported directly, the same shape `plot-reap.sh` uses for
1909
- # `reapable.ts`. Node 24 strips the types, so there is no build step between
1910
- # this script and the decision it asks for, and there is no second copy of the
1911
- # default, the subtraction or the machine's veto living in shell.
1912
- #
1913
- # A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. Missing node, a
1914
- # failed import, a module that throws all leave the answer empty. The
1915
- # direction is the reaper's: silence is never permission, and here permission
1916
- # would spawn detached processes.
1907
+ # THE COUNT IS THE RULE'S, and the rule is asked through its BUNDLE —
1908
+ # `board/plot-fleet-size.mjs`, tracked in git beside the other 24. There is no
1909
+ # second copy of the default, the subtraction or the machine's veto living in
1910
+ # shell.
1911
+ #
1912
+ # A SOURCE IMPORT CANNOT REACH A PLUGIN INSTALL, and this block used to be
1913
+ # one. It imported `rules/fleet-size.ts` and `entities/machine.ts` as `file://`
1914
+ # sources. Node 24 strips types, so the TypeScript was never the obstacle — the
1915
+ # SECOND import is: `machine.ts` opens with `import { z } from 'zod'`, and an
1916
+ # install carrying no `node_modules` cannot resolve it. Measured 2026-09-17
1917
+ # against a copy with no `node_modules` on the path:
1918
+ #
1919
+ # machine.ts FAILED: Cannot find package 'zod'
1920
+ # fleet-size.ts: imported
1921
+ #
1922
+ # So the bundle carries BOTH rules with `zod` bundled in. `a-shell-script-asks
1923
+ # -the-domain` settled the shape: a bundle under `skills/plot/scripts/board/`
1924
+ # is how a shell script reaches a rule, and a skill's own script directory is
1925
+ # what a plugin ships.
1917
1926
  #
1918
1927
  # TWO MODULES, BECAUSE THE VERDICT AND THE COUNT ARE TWO RULES. `headroomFor`
1919
1928
  # owns what a fork cost MEANS and `fleetSize` owns what to do about it; the
1920
1929
  # count rule takes the verdict as a reading rather than deriving it, so the
1921
- # thresholds have exactly one home and this block is the join.
1922
- #
1923
- # IMPORTED AS `.ts` WITH NO `.js` REWRITING. Node 24 strips types but does not
1924
- # remap a relative specifier, so `fleet-size.ts` may only `import type` from
1925
- # its neighbours — which is why the verdict arrives as a value here rather
1926
- # than being computed inside the rule.
1927
- start_domain="$(cd "$script_dir/../../.." 2>/dev/null && pwd)/packages/domain/src"
1928
- start_rule="file://$start_domain/rules/fleet-size.ts"
1929
- start_answer=$(PLOT_REQUESTED="$start_count" PLOT_RUNNING="$start_running" \
1930
- PLOT_COST="$start_cost" PLOT_RULE="$start_rule" \
1931
- PLOT_MACHINE="file://$start_domain/entities/machine.ts" \
1932
- node --input-type=module - <<'NODE_EOF' 2>/dev/null
1933
- const { fleetSize, DEFAULT_FLEET_SIZE } = await import(process.env.PLOT_RULE);
1934
- const { headroomFor } = await import(process.env.PLOT_MACHINE);
1935
-
1936
- // AN ABSENT COUNT IS THE RULE'S DEFAULT, resolved here rather than in the
1937
- // shell: the number and the argument for it have one home.
1938
- const requested =
1939
- process.env.PLOT_REQUESTED === "" ? DEFAULT_FLEET_SIZE : Number(process.env.PLOT_REQUESTED);
1940
-
1941
- // An UNMEASURED cost is null, never zero: zero is the fastest fork there is and
1942
- // would read as the clearest possible machine.
1943
- const spawnCostMs = process.env.PLOT_COST === "" ? null : Number(process.env.PLOT_COST);
1944
-
1945
- const answer = fleetSize({
1946
- requested,
1947
- running: Number(process.env.PLOT_RUNNING),
1948
- spawnCostMs,
1949
- headroom: headroomFor(spawnCostMs),
1950
- });
1951
-
1952
- process.stdout.write(`${answer.start}\t${answer.headroom}\t${answer.shortfall}`);
1953
- NODE_EOF
1954
- )
1930
+ # thresholds have exactly one home and the bundle's entry is the join.
1931
+ #
1932
+ # A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. A missing bundle, a
1933
+ # missing node, a module that throws all leave the answer empty. The direction
1934
+ # is the reaper's: silence is never permission, and here permission would spawn
1935
+ # detached processes.
1936
+ start_bundle="$script_dir/board/plot-fleet-size.mjs"
1937
+ start_answer=$(printf '%s\t%s\t%s' "$start_count" "$start_running" "$start_cost" \
1938
+ | node "$start_bundle" 2>/dev/null)
1955
1939
 
1956
1940
  if [ -z "$start_answer" ]; then
1941
+ # THE REFUSAL NAMES THE CONDITION THAT FAILED, never two that hold. The
1942
+ # message this replaced said *"it needs node 24 and a readable checkout of
1943
+ # packages/domain"* to an operator whose node was 24.4.1 and whose checkout
1944
+ # was readable — the import it could not resolve was named nowhere. A
1945
+ # refusal that names the wrong condition costs more than one that says
1946
+ # nothing, because it looks actionable. So the two causes are separated and
1947
+ # tested in the order that distinguishes them: an absent bundle is a broken
1948
+ # or partial installation, and a present bundle that answered nothing is the
1949
+ # runtime underneath it.
1957
1950
  echo "plot-dispatch: --start could not ask how many agents to start — starting none." >&2
1958
- echo " The rule is $start_rule" >&2
1959
- echo " It needs node 24 and a readable checkout of packages/domain." >&2
1951
+ if [ ! -f "$start_bundle" ]; then
1952
+ echo " The rule's bundle is missing: $start_bundle" >&2
1953
+ echo " Every bundle is tracked in git, so this is a broken or partial installation." >&2
1954
+ echo " In a development checkout, run 'pnpm build:board'." >&2
1955
+ else
1956
+ start_node_v="$(node --version 2>/dev/null)" || start_node_v=""
1957
+ echo " The rule's bundle is $start_bundle" >&2
1958
+ if [ -z "$start_node_v" ]; then
1959
+ echo " No usable 'node' was found on PATH. The bundle needs node 20 or newer." >&2
1960
+ else
1961
+ echo " The bundle is present but answered nothing under node $start_node_v." >&2
1962
+ echo " Run it directly to see why: printf '%s\\t%s\\t%s' '$start_count' '$start_running' '$start_cost' | node '$start_bundle'" >&2
1963
+ fi
1964
+ fi
1960
1965
  exit 1
1961
1966
  fi
1962
1967
 
1968
+ # THREE FIELDS, AND THE SENTENCE IS LAST. `start_why` is printed to the
1969
+ # operator below, so it travels; taking it as the whole remainder means a
1970
+ # shortfall can never be truncated by its own punctuation.
1963
1971
  start_n=${start_answer%%$'\t'*}
1964
1972
  start_rest=${start_answer#*$'\t'}
1965
1973
  start_headroom=${start_rest%%$'\t'*}
@@ -89,6 +89,9 @@
89
89
  # Output: per-plan wave report on stdout, terminated by a machine-countable
90
90
  # summary line:
91
91
  # summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 waiting=1 prereq_missing=0 merge_detect=pr-merge host=ok main=main
92
+ # `host` is one of ok, partial, throttled, secondary, failed, unasked —
93
+ # `partial` means some of the host's states answered and some did not,
94
+ # so the PR readings below are incomplete rather than absent.
92
95
  # `blocked` counts WAVES an earlier wave holds; `waiting` and
93
96
  # `prereq_missing` count BRANCHES their `waits:` annotation holds.
94
97
  # merge_detect names how merged-and-deleted branches were detected:
@@ -631,6 +634,19 @@ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
631
634
  # secondary — a burst refusal (`plot-host.sh` exit 6). Nothing is broken
632
635
  # either, and it clears in seconds rather than minutes.
633
636
  # failed — any other failure (exit 3, or anything unclassified).
637
+ # partial — SOME of the host's states answered and some did not
638
+ # (`plot-host.sh` exit 7). The rows that arrived are real and
639
+ # are parsed; what is missing is a whole state, so the reading
640
+ # is incomplete rather than absent. Only Bitbucket can produce
641
+ # it: `bb pr list` has no `all` state, so the arm asks once per
642
+ # state, while GitHub takes `--state all` in one call.
643
+ #
644
+ # IT IS NOT `ok` AND IT IS NOT `failed`. Reporting `ok` would
645
+ # serve a page missing a state as a complete answer — #912, where
646
+ # nine branches read `commits, no PR ever opened` and two had
647
+ # live PRs. Reporting `failed` would throw away rows that
648
+ # arrived and make every branch `unknown`, which is not
649
+ # startable — the right refusal about the wrong thing.
634
650
  # unasked — no host to ask, or --offline WITHOUT `--next`. Not a
635
651
  # degradation: the scan was never going to ask, and saying
636
652
  # `failed` would report a fault where there is a configuration.
@@ -644,6 +660,43 @@ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
644
660
  # secondary limit waits minutes for a ceiling that cleared in seconds.
645
661
  HOST_VERDICT=unasked
646
662
 
663
+ # The remote branches this scan tracks, read once for every question that asks.
664
+ #
665
+ # MOVED UP FROM ITS OLD POSITION (#333) so the host call below can be told which
666
+ # branches to ask about. It is the same single `for-each-ref` it always was —
667
+ # see the commentary at its old site — and the reasons it exists are unchanged:
668
+ # `git show-ref --verify` was asked once per branch from two places, and
669
+ # `%(objectname)` rides along free for the commit walk.
670
+ REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
671
+ "refs/remotes/origin" </dev/null 2>/dev/null)
672
+
673
+ # The branch names alone, space-separated — what the sweep asks the host about.
674
+ #
675
+ # THE JOIN'S OWN KEYS, AND NOTHING WIDER. `prefill_pr_states` indexes the host's
676
+ # reply by branch and every row it cannot key is discarded, so the set this asks
677
+ # about is exactly the set that could ever be used. Measured 2026-09-20 on
678
+ # `quatico/quaweb-website`: 11 remote branches against 902 pull requests, of
679
+ # which a listing hands over 50 — the sweep asks 11 questions and gets 11
680
+ # answers, where the listing asked one and answered for 5%.
681
+ #
682
+ # `HEAD` IS DROPPED. `refs/remotes/origin/HEAD` is a symbolic ref naming the
683
+ # default branch, not a branch of its own; asking the host about a branch called
684
+ # `HEAD` spends a query to learn that nothing is named that.
685
+ #
686
+ # SPACE-SEPARATED, AND GIT IS WHAT MAKES THAT SAFE. Both this list and the
687
+ # adapter's `PR_LIST_BRANCHES` are read by an unquoted `for`, so a name carrying
688
+ # whitespace would split into two branches that do not exist. `git
689
+ # check-ref-format` REFUSES a ref name containing a space or a tab — verified
690
+ # 2026-09-20, both exit non-zero — so no such branch can reach this, and the
691
+ # separator is git's guarantee rather than a hopeful convention.
692
+ #
693
+ # EMPTY IS A REAL ANSWER AND IT DISABLES THE SWEEP. A checkout with no remote
694
+ # refs has no branches to ask about, and a sweep of nothing would state that
695
+ # every tracked branch answered — a completeness claim over an empty set, which
696
+ # would license `NONE` for branches nobody asked about. `prefill_pr_states`
697
+ # falls back to the listing there, which is what it has always done.
698
+ TRACKED_BRANCHES=$(printf '%s\n' "$REMOTE_REFS" | cut -f1 | grep -v '^HEAD$' | grep -v '^$' | tr '\n' ' ')
699
+
647
700
  prefill_pr_states() {
648
701
  [ "$HOST_LOOKUP_OK" = 1 ] || return 0
649
702
  [ -n "$HOST_STATE_CACHE" ] || return 0
@@ -672,10 +725,34 @@ prefill_pr_states() {
672
725
  # and /dev/null keeps the call working with the text simply unavailable.
673
726
  host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
674
727
  host_list_out="${host_list_out:-/dev/null}"
728
+ # THE BRANCHES THIS SCAN TRACKS, HANDED TO THE HOST (#333). The adapter uses
729
+ # them only where it can — the Bitbucket arm sweeps its REST endpoint once per
730
+ # branch per state — and ignores them everywhere else, so the GitHub arm makes
731
+ # the single call it always made. Passing them unconditionally keeps one call
732
+ # shape here rather than a backend test this script has no business making.
733
+ #
734
+ # AN EMPTY SET PASSES NOTHING and the adapter lists as before. See
735
+ # `TRACKED_BRANCHES`: a completeness claim over an empty set would license
736
+ # `NONE` for branches nobody asked about.
737
+ _branch_args=()
738
+ for _tb in $TRACKED_BRANCHES; do _branch_args+=(--branch "$_tb"); done
675
739
  host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
740
+ ${_branch_args[@]+"${_branch_args[@]}"} \
676
741
  </dev/null 2>&1 >"$host_list_out"); rc=$?
677
742
  js=$(cat "$host_list_out" 2>/dev/null)
678
- if [ "$rc" -ne 0 ]; then
743
+ # A PARTIAL ANSWER TAKES THE PARSE PATH AND STILL DEGRADES THE VERDICT, which
744
+ # is a control-flow change rather than another `case` arm below: every other
745
+ # non-zero rc sets a verdict and returns BEFORE `$js` is read, because there
746
+ # is nothing to read. Here there is — the rows of the states that answered are
747
+ # already in `host_list_out`, since stdout is redirected to a file and stderr
748
+ # captured separately.
749
+ #
750
+ # BOTH HALVES ARE REQUIRED. Falling through without setting the verdict would
751
+ # report `ok` over a page missing a whole state, which is #912; returning
752
+ # early would throw away rows the host did answer with.
753
+ if [ "$rc" -eq 7 ]; then
754
+ HOST_VERDICT=partial
755
+ elif [ "$rc" -ne 0 ]; then
679
756
  # THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
680
757
  # never put" (see HOST_VERDICT above: *not a degradation, the scan was
681
758
  # never asking*), and a host that cannot be ASKED AT ALL belongs there
@@ -743,7 +820,12 @@ prefill_pr_states() {
743
820
  return 0
744
821
  fi
745
822
  # The list arrived. An empty one arrived too — that is the whole distinction.
746
- HOST_VERDICT=ok
823
+ #
824
+ # A PARTIAL VERDICT IS NOT OVERWRITTEN HERE. Exit 7 reaches this line
825
+ # deliberately, because its rows must be parsed; an unguarded `ok` would
826
+ # undo the one thing that distinguishes an incomplete page from a whole one
827
+ # and report #912 as a healthy reading.
828
+ [ "$HOST_VERDICT" = partial ] || HOST_VERDICT=ok
747
829
  # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
748
830
  # that is a correctness-of-cost property rather than a style preference:
749
831
  # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
@@ -864,9 +946,39 @@ EOF
864
946
  # A repository genuinely holding zero PRs loses nothing by being asked: it has
865
947
  # no branches with PRs for the join to serve either, so the cost is zero calls
866
948
  # in both readings.
867
- if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
868
- printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
869
- fi
949
+ #
950
+ # A SWEEP STATES ITS COMPLETENESS; A PAGE ONLY EVER IMPLIED IT (#333). The
951
+ # test above reads completeness off one page's size, which is the only
952
+ # evidence a listing offers — and on Bitbucket it is evidence the listing
953
+ # cannot give at all, since `bb pr list` returns a fixed 50 whether or not
954
+ # more exist. Where the adapter swept per branch it says so on stderr, naming
955
+ # both counts, and that sentence is a stronger claim than any row count: every
956
+ # tracked branch was asked and each one answered.
957
+ #
958
+ # THE ROW COUNT IS NOT CONSULTED ON THAT PATH, and it must not be. A sweep
959
+ # over 11 branches of which 2 have pull requests emits 2 rows — a true and
960
+ # complete answer that `0 < rows < PR_LIST_LIMIT` would also accept, but for
961
+ # the wrong reason, and which a sweep of 0 matches would fail outright despite
962
+ # being equally complete. Reading the claim the adapter made is exact where
963
+ # re-deriving it from the output is a coincidence.
964
+ #
965
+ # THE WORDING IS A CONTRACT between this script and `plot-host.sh`'s
966
+ # `pr_sweep_report`, pinned on both sides. A partial sweep never prints it, so
967
+ # a match is licence and a miss is silence — never a guess.
968
+ #
969
+ # WHAT IS LOST BY GETTING THIS WRONG IS COST, NOT CORRECTNESS. Without the
970
+ # marker, `host_pr_state --ask` falls through to one `pr-state` call per
971
+ # unjoined branch and still answers correctly — the per-branch N+1 that #216
972
+ # removed. That is why withholding the marker is always the safe direction and
973
+ # is what every failure path here does.
974
+ case "$host_err" in
975
+ *"pr-list sweep complete"*)
976
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
977
+ *)
978
+ if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
979
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
980
+ fi ;;
981
+ esac
870
982
  }
871
983
  prefill_pr_states
872
984
 
@@ -1487,8 +1599,12 @@ worktree_locked() { # $1=worktree path → 0 when a lock is held there
1487
1599
  # on the population that must stay free. The walk here is a subject/emptiness
1488
1600
  # question rather than a timestamp read, but the guard is deliberately broad and
1489
1601
  # loosening it to fit this change is how a guard rots.
1490
- REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
1491
- "refs/remotes/origin" </dev/null 2>/dev/null)
1602
+ # READ ABOVE `prefill_pr_states`, not here. The per-branch sweep (#333) hands
1603
+ # the host the branches this scan tracks, and that list is exactly what this
1604
+ # batch already answers — so the assignment moved up rather than a second
1605
+ # `for-each-ref` being added beside it. Everything documented above still
1606
+ # describes it; only the line's position changed, and it depends on nothing but
1607
+ # git, so nothing between the two points can read a different answer.
1492
1608
 
1493
1609
  # Whether `origin/$1` exists, answered from the batch rather than by spawning.
1494
1610
  #
@@ -4265,6 +4381,17 @@ elif [ "$HOST_VERDICT" = failed ]; then
4265
4381
  echo " branch below reads from local evidence alone, and a branch whose"
4266
4382
  echo " PR is unknown reads 'unknown' rather than 'open'. This is not a"
4267
4383
  echo " rate limit — waiting will not clear it; check the host and auth."
4384
+ # THE PAGE IS SHORT, NOT ABSENT, and that is a different instruction to a
4385
+ # reader. The three notes above all say *no PR could be read*; here some were,
4386
+ # so the branches below are a MIXTURE — a branch shown without a PR may have one
4387
+ # in the state that failed. Telling a reader to treat this as an outage would
4388
+ # discard the rows that arrived; telling them nothing is #912, where nine
4389
+ # branches read as having no PR and two had live ones.
4390
+ elif [ "$HOST_VERDICT" = partial ]; then
4391
+ echo " note: the git host answered for some states and not others, so the PR"
4392
+ echo " list below is INCOMPLETE. A branch shown without a PR may have one"
4393
+ echo " in the state that failed — do not read this as evidence that a"
4394
+ echo " branch is unreviewed. Re-run to get the whole list."
4268
4395
  fi
4269
4396
  # A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
4270
4397
  # hour-old refs read exactly like a scan of current ones — the same