@plot-pm/board 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,14 +23,18 @@
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
25
  "plot-agent-monitor.sh",
26
+ "plot-build-monitor.sh",
27
+ "plot-monitor-subject.sh",
26
28
  "plot-approve.sh",
27
29
  "plot-budget.sh",
28
30
  "plot-config.sh",
31
+ "plot-default-branch.sh",
29
32
  "plot-deliver.sh",
30
33
  "plot-dispatch.sh",
31
34
  "plot-fleet-scan.sh",
32
35
  "plot-host.sh",
33
36
  "plot-plan-meta.sh",
37
+ "plot-pr-merged.sh",
34
38
  "plot-reap.sh",
35
39
  "plot-release-refs.sh",
36
40
  "plot-resolve-artifact.sh",
@@ -227,21 +227,45 @@ publish() { # $1=finding $2=evidence $3=since
227
227
  # helpers throw away: `gh` absent, unauthed, or failing is `unaskable`, and an
228
228
  # unaskable host produces NO finding rather than a wrong one. A failure to
229
229
  # observe is not evidence of something to see.
230
+ #
231
+ # THE HOST IS ASKED THROUGH `plot-host.sh`, never `gh` directly. This port
232
+ # called `gh pr list` until 2026-09-05, so the monitor could see a PR on GitHub
233
+ # and nowhere else: on Bitbucket `command -v gh` failed and every branch on the
234
+ # estate read `unaskable` — the refusal was right about the wrong thing, since
235
+ # the host was reachable and simply not GitHub. `pr-state` answers on both.
236
+ #
237
+ # THE ADAPTER'S TWO OUTCOMES ARE ALREADY THIS PORT'S TWO. `pr-state` exits 0
238
+ # with `state:"NONE"` when the host answered and there is no PR, and non-zero
239
+ # when the call itself failed — which is exactly the line between `no PR` and
240
+ # `unaskable` that the `gh` version drew with `|| return 2`. So the split is
241
+ # read from the exit status and the payload together, and neither answer moved.
230
242
  monitor_pr_state() { # → 0 has a PR | 1 no PR | 2 unaskable
231
243
  [ -n "$branch" ] || return 2
232
- command -v gh >/dev/null 2>&1 || return 2
233
- local out
234
- # `--state all` and `mergedAt`, never `state`: a merged PR reports CLOSED, and
235
- # squash-merge leaves the branch ahead of main forever, so neither the state
236
- # word nor ancestry can answer this. The same reading `plot-pr-merged.sh`
237
- # makes, and deliberately the same query, so the two never disagree about a
238
- # branch.
239
- out=$(gh pr list --head "$branch" --state all --limit 100 --json mergedAt,number 2>/dev/null) || return 2
240
- # An empty LIST is a real answer the host was asked and said none. Only a
241
- # failed call is unaskable, which is what the `|| return 2` above separates.
242
- case "$out" in
243
- *'"number"'*) return 0 ;;
244
- *) return 1 ;;
244
+ local host_script out state
245
+ host_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-host.sh"
246
+ [ -r "$host_script" ] || return 2
247
+ # `</dev/null` so a host CLI that decides to prompt cannot hang a monitor
248
+ # whose whole contract is one bounded reading per pass.
249
+ out=$(bash "$host_script" pr-state "$branch" </dev/null 2>/dev/null) || return 2
250
+ # THE EXIT STATUS DECIDES WHETHER THE HOST WAS ASKED; the payload only says
251
+ # what it answered. `pr-state` exits non-zero when the call failed and 0 when
252
+ # it did not, so anything reaching this line is an answer.
253
+ #
254
+ # A MISS IS `NONE`, AND SO IS A NULL. `state:"NONE"` is the adapter's word for
255
+ # "no PR found", but the same miss reaches here as `state:null` when the host
256
+ # CLI answers with a payload carrying no PR — measured 2026-09-05 against
257
+ # `test/e2e/agent-monitor-reads.test.mjs`, where `gh pr view` on a branch with
258
+ # no PR produced `{"number":null,"state":null,…}` and exit 0.
259
+ #
260
+ # Reading that null as `unaskable` is what the first draft of this port did,
261
+ # and it is wrong in the direction that hides the finding: an agent that
262
+ # committed and opened nothing produced no `owes a review` at all, because the
263
+ # monitor believed it had never measured. A failure to observe is not evidence
264
+ # of something to see — but neither is an observation evidence of a failure.
265
+ state=$(printf '%s' "$out" | jq -r '.state // "NONE"' 2>/dev/null) || return 2
266
+ case "$state" in
267
+ NONE|null|"") return 1 ;;
268
+ *) return 0 ;;
245
269
  esac
246
270
  }
247
271
 
package/plot-approve.sh CHANGED
@@ -116,7 +116,7 @@ done
116
116
  die() { echo "plot-approve: $*" >&2; exit 1; }
117
117
 
118
118
  [ -n "$slug" ] || die "need a plan slug (usage: plot-approve.sh [--dry-run] <slug>)"
119
- git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
119
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run this from inside the checkout, or 'git init' one here"
120
120
 
121
121
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
122
122
 
@@ -135,10 +135,14 @@ plan_file=""
135
135
  for cand in "$ACTIVE_DIR$slug.md" "$PLAN_DIR"*"$slug".md; do
136
136
  [ -e "$cand" ] && { plan_file="$cand"; break; }
137
137
  done
138
- [ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $ACTIVE_DIR and $PLAN_DIR"
138
+ [ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $ACTIVE_DIR and $PLAN_DIR.
139
+ Check the slug: ls $PLAN_DIR | grep -i '$slug'
140
+ Or create the plan first: /plot-idea"
139
141
 
140
142
  meta=$(bash "$script_dir/plot-plan-meta.sh" "$plan_file" 2>/dev/null) || meta=""
141
- [ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
143
+ [ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing.
144
+ See what the parser reads: $script_dir/plot-plan-meta.sh $plan_file
145
+ A plan needs a '## Status' section with a 'State:' field."
142
146
 
143
147
  jfield() { printf '%s' "$meta" | jq -r "$1" 2>/dev/null; }
144
148
 
@@ -163,11 +167,14 @@ plan_branches=$(jfield '.branches[]?')
163
167
  case "$phase" in
164
168
  draft|design|approved) ;;
165
169
  delivered|released)
166
- die "plan '$slug' is already $phase — nothing to approve." ;;
170
+ die "plan '$slug' is already $phase — nothing to approve.
171
+ Nothing to do here. To take the work further: /plot-release" ;;
167
172
  NONE|"")
168
- die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing." ;;
173
+ die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing.
174
+ Its '## Status' section needs a line reading '- **State:** Draft'." ;;
169
175
  *)
170
- die "plan '$slug' is in phase '$phase' — only a Draft or Design plan can be approved." ;;
176
+ die "plan '$slug' is in phase '$phase' — only a Draft or Design plan can be approved.
177
+ If that phase is wrong, correct the 'State:' line in $plan_file and push it." ;;
171
178
  esac
172
179
 
173
180
  # --- refusal 2: the review channel ------------------------------------------
@@ -225,9 +232,12 @@ case "$pr_state" in
225
232
  MERGED) ;;
226
233
  OPEN) ;;
227
234
  CLOSED)
228
- die "the plan PR for '$slug' (#$pr_number) is closed. Reopen it or create a new one." ;;
235
+ die "the plan PR for '$slug' (#$pr_number) is closed.
236
+ Reopen it on the host, or push '$pr_branch' again and open a new one." ;;
229
237
  NONE|*)
230
- die "no PR found for branch '$pr_branch'. Run /plot-idea first, or push the branch." ;;
238
+ die "no PR found for branch '$pr_branch'.
239
+ Push the branch: git push -u origin $pr_branch
240
+ Then open its PR — or run /plot-idea, which does both." ;;
231
241
  esac
232
242
 
233
243
  echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
@@ -336,10 +346,14 @@ real_plan_path() { # $1 = plan file as found
336
346
  }
337
347
 
338
348
  rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
339
- [ -n "$rel" ] || die "$plan_file is outside the repository root"
349
+ [ -n "$rel" ] || die "$plan_file is outside the repository root ($repo_root).
350
+ Move the plan under $PLAN_DIR inside this checkout and re-run."
340
351
 
341
- # Flip `**Phase:** Draft` OR `**Phase:** Design` → `Approved` in the `## Status`
342
- # section only. Both are the pre-Approved phases this script advances from.
352
+ # Flip `**State:** Draft` OR `**State:** Design` → `Approved` in the `## Status`
353
+ # section only. Both are the pre-Approved states this script advances from.
354
+ #
355
+ # Reads `State:` and `Phase:` alike: it changes the VALUE on whichever line
356
+ # carries it, so a plan written before the 2026-09-07 rename still approves.
343
357
  #
344
358
  # Scoped to that section because a plan that QUOTES a status block in its prose
345
359
  # (this repo has several, documenting the format) would otherwise have its
@@ -353,7 +367,7 @@ flip_phase() { # $1=in $2=out → 0 if it changed the file, 1 if there was noth
353
367
  awk '
354
368
  BEGIN { section = ""; done = 0 }
355
369
  /^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
356
- section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ {
370
+ section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**(state|phase)[:*]/ {
357
371
  if (tolower($0) ~ /draft/) {
358
372
  sub(/[Dd]raft/, "Approved")
359
373
  done = 1
@@ -555,13 +569,20 @@ clear_holds() { # $1=worktree root; reads $plan_branches
555
569
  }
556
570
 
557
571
  # Update the sprint item annotation this plan appears in:
558
- # - [ ] [slug] description <!-- pr: #N, status: draft, branch: feature/slug -->
559
- # /plot-sprint READS these (`pr`, `status`, `branch`) and /plot-approve writes
560
- # them, so an approval that skips this makes `/plot-sprint status` wrong rather
561
- # than merely incomplete.
572
+ # - [ ] [slug] description <!-- pr: #N, branch: feature/slug -->
573
+ # /plot-sprint READS these (`pr`, `branch`) and /plot-approve writes them, so an
574
+ # approval that skips this makes `/plot-sprint status` wrong rather than merely
575
+ # incomplete.
576
+ #
577
+ # `status:` IS GONE, since 2026-09-08. `a-withdrawn-item-is-not-open` measured
578
+ # it dead in both directions — 67 lines carried one, no reader acted on the
579
+ # value, and `plot-sprint-release.sh` read the field nowhere. The plan file
580
+ # carries `State:` and a dated `Approved:` record; a cache nobody refreshes and
581
+ # nobody reads is a second answer waiting to contradict the first. `pr` and
582
+ # `branch` stay: they name things no plan field holds.
562
583
  #
563
584
  # A plan in NO sprint is a no-op, never an error — that is the common case.
564
- # Already-done test: the annotation already carries `status: approved`.
585
+ # Already-done test: the annotation already carries this PR and branch.
565
586
  update_sprint_annotation() { # $1=worktree root → prints none|updated|already|missing
566
587
  local root="$1" f found=""
567
588
  [ -n "$sprint" ] || { printf 'none'; return 0; }
@@ -586,18 +607,27 @@ update_sprint_annotation() { # $1=worktree root → prints none|updated|already|
586
607
  {
587
608
  line = $0
588
609
  if (index(line, "<!--") == 0) {
589
- line = line " <!-- pr: #" pr ", status: approved" (br != "" ? ", branch: " br : "") " -->"
590
- } else {
591
- if (line ~ /status:[ \t]*[a-z-]+/) sub(/status:[ \t]*[a-z-]+/, "status: approved", line)
592
- else sub(/-->/, ", status: approved -->", line)
593
- if (line ~ /pr:[ \t]*#?[0-9a-z]+/) sub(/pr:[ \t]*#?[0-9a-z]+/, "pr: #" pr, line)
594
- else sub(/<!--/, "<!-- pr: #" pr ",", line)
595
- if (br != "") {
596
- if (line ~ /branch:[ \t]*[^,>]+/) sub(/branch:[ \t]*[^,>]*[^,> \t]/, "branch: " br, line)
597
- else sub(/-->/, ", branch: " br " -->", line)
598
- }
610
+ print line " <!-- pr: #" pr (br != "" ? ", branch: " br : "") " -->"
611
+ next
612
+ }
613
+ # THE CLOSING MARKER IS SPLIT OFF BEFORE ANY FIELD IS TOUCHED.
614
+ # A value pattern that has to avoid `-->` cannot be written safely:
615
+ # `[^,>]*[^,> \t]` excluded the `>` and still swallowed the `--`, turning
616
+ # ` -->` into a bare `>` and corrupting the comment — measured 2026-09-08,
617
+ # `approve: updates the sprint annotation the sprint view reads` failed on
618
+ # exactly that. Excluding `-` as well is worse: it rewrites `bug/a-b` as
619
+ # `feature/new-b`, and hyphens are in most branch names here.
620
+ # With the marker held aside, every field ends at a comma or at
621
+ # end-of-string, and none can reach it.
622
+ tail = ""
623
+ if (match(line, /[ \t]*-->[ \t]*$/)) { tail = substr(line, RSTART); line = substr(line, 1, RSTART - 1) }
624
+ if (line ~ /pr:[ \t]*#?[0-9a-z]+/) sub(/pr:[ \t]*#?[0-9a-z]+/, "pr: #" pr, line)
625
+ else sub(/<!--/, "<!-- pr: #" pr ",", line)
626
+ if (br != "") {
627
+ if (line ~ /branch:[ \t]*[^,]/) sub(/branch:[ \t]*[^,]*/, "branch: " br, line)
628
+ else line = line ", branch: " br
599
629
  }
600
- print line
630
+ print line (tail != "" ? tail : " -->")
601
631
  }
602
632
  ' "$found")
603
633
  if [ "$before" = "$after" ]; then printf 'already'; return 0; fi
@@ -656,7 +686,9 @@ if [ "$same_branch" = 1 ]; then
656
686
  push_report="nothing-to-commit"
657
687
  echo "step: nothing to commit — the approval was already recorded"
658
688
  else
659
- git -C "$repo_root" commit -q -m "plot: approve $slug" || die "could not commit the approval"
689
+ git -C "$repo_root" commit -q -m "plot: approve $slug" || die "could not commit the approval.
690
+ The PR is already merged; the local record is what is missing. See what git
691
+ refused: git -C $repo_root status. Then re-run this — it is idempotent."
660
692
  push_report="local"
661
693
  echo "step: recorded on $(git -C "$repo_root" branch --show-current) — push it with the implementation"
662
694
  fi
@@ -672,7 +704,10 @@ else
672
704
  # -B: a leftover branch from an earlier failed run must not block this one.
673
705
  # It is disposable by construction — created here, pushed, deleted.
674
706
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
675
- || die "could not prepare a booking worktree at $tmpwt"
707
+ || die "could not prepare a booking worktree at $tmpwt.
708
+ Most often origin/$MAIN is not fetched, or '$bookbr' is checked out in
709
+ another worktree. Check both: git fetch origin $MAIN && git worktree list
710
+ Nothing has been written locally; the plan is untouched."
676
711
 
677
712
  cleanup() {
678
713
  git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true