@plot-pm/board 0.6.0-rc.21 → 0.6.0-rc.210

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.6.0-rc.21",
3
+ "version": "0.6.0-rc.210",
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",
@@ -32,7 +32,7 @@
32
32
  "build": "pnpm run build:client && pnpm run build:server",
33
33
  "prepack": "pnpm run build",
34
34
  "typecheck": "tsc --noEmit",
35
- "test": "node --test test/*.test.mjs",
35
+ "test": "node --test --test-concurrency=4 test/*.test.mjs",
36
36
  "test:integration": "pnpm build && vitest run"
37
37
  },
38
38
  "devDependencies": {
package/plot-config.sh CHANGED
@@ -40,6 +40,17 @@
40
40
  # Approve command how the board runs `/plot-approve <slug>`; the prompt is
41
41
  # appended as one argument. Absent = the board's Approve
42
42
  # button renders disabled, naming this key as the fix.
43
+ # Idea command how the board runs `/plot-idea` on a tracker issue; the
44
+ # prompt is appended as one argument, and it names a FILE
45
+ # the board wrote (an issue body is free text from anyone
46
+ # who can file an issue, so no part of it is ever a shell
47
+ # word). REQUIRED for the issue row's `Create plan`
48
+ # action, unlike `Approve command`: approving has a
49
+ # script to fall back to, and creating a plan does not —
50
+ # every step of /plot-idea is judgement, and no script
51
+ # here can invoke a skill. Absent (or `none`) = the button
52
+ # refuses and names this key as the fix, rather than
53
+ # accepting the click and doing nothing.
43
54
  # Plot 2 posture keys (repo-declared ceremony bounds; all optional):
44
55
  # Plan PRs required | never | optional (never = hard gate)
45
56
  # Implementation home this repo | <repo/path list> | none
package/plot-plan-meta.sh CHANGED
@@ -45,8 +45,14 @@
45
45
  # file the path given
46
46
  # format canonical | frontmatter | none
47
47
  # phase_raw primary phase value as written ("" if absent)
48
- # phase normalized: draft|approved|delivered|released|rejected|
49
- # superseded|UNKNOWN|NONE
48
+ # phase normalized: draft|design|approved|delivered|released|
49
+ # rejected|superseded|UNKNOWN|NONE
50
+ # `design` is a phase of its own, not a synonym for anything:
51
+ # a plan in Design cannot yet be handed to development because
52
+ # it needs a spec, a spike or a tracer bullet first. That is a
53
+ # different statement from "approved and nobody has started",
54
+ # which is a queue. (`ready-for-review`/`in-review` DO
55
+ # normalize onto `approved` — those are synonyms; this is not.)
50
56
  # phase_alt_raw secondary value when the file carries two (front matter
51
57
  # status: AND phase:), else ""
52
58
  # phase_alt normalized phase_alt_raw (NONE when absent)
@@ -66,8 +72,26 @@
66
72
  # `<!-- claimed: ... -->`) bind to the LINE carrying the
67
73
  # backticked branch name. An annotation on a wrapped
68
74
  # continuation line is not seen — keep it on the branch line.
75
+ # `<!-- deferred -->` (bare, no colon) sets the flag with no
76
+ # reason; `waves[].branches[].deferred_reason` carries the
77
+ # sentence after the colon, "" where none was written.
69
78
  # prs PR numbers from `→ #NNN` links in the `## Branches`
70
79
  # section (sorted, unique)
80
+ # changelog the plan's `## Changelog` entries, one string per bullet, in
81
+ # document order; [] when the plan has no changelog or an
82
+ # unfilled one. This is the one field that says WHAT A PLAN
83
+ # CHANGES, which title and story do not.
84
+ # ENTRIES, not lines: a bullet wrapped across several lines is
85
+ # one entry with the continuation lines joined by a single
86
+ # space (9 of the 34 changelogs in the origin repo wrap, so
87
+ # line-per-line would have shredded a quarter of them). An
88
+ # INDENTED bullet folds into the entry above it the same way:
89
+ # a sub-point is not a release note of its own.
90
+ # Non-bullet prose in the section is NOT an entry — 8 plans
91
+ # close their changelog with a "Board impact:" paragraph, which
92
+ # is a note to a reviewer rather than a release note. Comment
93
+ # interiors stay non-content, as everywhere else in this parser,
94
+ # so the template's guidance block contributes nothing.
71
95
  # issues tracker issue numbers this plan answers, from the `## Status`
72
96
  # `Issue:` line or front matter `issue:` (sorted, unique).
73
97
  # A DEDICATED field, never a scan of the body for `#NNN`: a
@@ -86,6 +110,12 @@
86
110
  # `Released:` or front matter `released:` — date and version).
87
111
  # Empty until /plot-release records it; the tag stays the git
88
112
  # truth and this merely reflects it.
113
+ # design_raw the design transition record as written (`## Status`
114
+ # `Design:` or front matter `design:` — who/when, and what the
115
+ # design work is); "" if absent. Reported for any plan that
116
+ # carries the line, whatever its phase: a plan that WENT
117
+ # THROUGH design keeps the record after moving on, exactly as
118
+ # `approved_raw` outlives the Approved phase.
89
119
  # approved_raw the approval transition record as written (`## Status`
90
120
  # `Approved:` or front matter `approved:` — who/when/channel,
91
121
  # e.g. "2026-07-30, alice, in-session"); "" if absent
@@ -138,7 +168,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
138
168
  fi
139
169
 
140
170
  for f in ${missing[@]+"${missing[@]}"}; do
141
- printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
171
+ printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"changelog":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
142
172
  "$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
143
173
  done
144
174
 
@@ -166,7 +196,7 @@ function norm_phase(raw, lower, toks, n, i, t) {
166
196
  for (i = 1; i <= n; i++) {
167
197
  t = toks[i]
168
198
  gsub(/^[^a-z]+/, "", t); gsub(/[^a-z-]+$/, "", t)
169
- if (t ~ /^(draft|approved|delivered|released|rejected|superseded)$/) return t
199
+ if (t ~ /^(draft|design|approved|delivered|released|rejected|superseded)$/) return t
170
200
  if (t == "ready-for-review" || t == "in-review") return "approved"
171
201
  }
172
202
  return "UNKNOWN"
@@ -211,11 +241,11 @@ function reset_state() {
211
241
  fm_status = ""; fm_phase = ""; fm_type = ""
212
242
  fm_title = ""; fm_sprint = ""; fm_story = ""; fm_assignee = ""
213
243
  fm_review = ""; fm_impl = ""; fm_approved = ""; fm_started = ""; fm_released = ""
214
- fm_delivered = ""
244
+ fm_delivered = ""; fm_design = ""
215
245
  canon_phase = ""; canon_type = ""
216
246
  canon_sprint = ""; canon_story = ""; canon_assignee = ""
217
247
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
218
- canon_delivered = ""
248
+ canon_delivered = ""; canon_design = ""
219
249
  h1_title = ""
220
250
  # "" means the plan carries no readable round — NOT zero. Emitted by omitting
221
251
  # the field, so a consumer cannot mistake "never interrogated" for "asked
@@ -227,10 +257,12 @@ function reset_state() {
227
257
  fm_issue = ""; canon_issue = ""
228
258
  delete issues; n_issues = 0
229
259
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
230
- delete deferred_of; delete claimed_of; delete ordered_b; n_waves = 0
260
+ delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
231
261
  delete started; n_started = 0
262
+ fm_changelog = ""
263
+ delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
232
264
  }
233
- function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, approved, delivered, issue, i, j, out, sorted_b, sorted_p, sorted_i, nb, np, ni) {
265
+ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, i, j, out, sorted_b, sorted_p, sorted_i, nb, np, ni) {
234
266
  if (fm_status != "" || fm_phase != "") {
235
267
  fmt = "frontmatter"
236
268
  praw = (fm_status != "") ? fm_status : fm_phase
@@ -249,6 +281,7 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
249
281
  assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
250
282
  review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
251
283
  impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
284
+ design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
252
285
  approved = strip_placeholder((fm_approved != "") ? fm_approved : canon_approved)
253
286
  released = strip_placeholder((fm_released != "") ? fm_released : canon_released)
254
287
  delivered = strip_placeholder((fm_delivered != "") ? fm_delivered : canon_delivered)
@@ -302,6 +335,15 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
302
335
  out = out "],\"issues\":["
303
336
  for (i = 1; i <= ni; i++) out = out (i > 1 ? "," : "") sorted_i[i]
304
337
  out = out "]"
338
+ # Document order, never sorted: a changelog is a narrative sequence, and the
339
+ # first entry is the headline. Front matter wins, as it does for every other
340
+ # field, and contributes exactly one entry.
341
+ if (fm_changelog != "" && strip_placeholder(fm_changelog) != "") {
342
+ delete changelog; n_changelog = 1; changelog[1] = strip_placeholder(fm_changelog)
343
+ }
344
+ out = out ",\"changelog\":["
345
+ for (i = 1; i <= n_changelog; i++) out = out (i > 1 ? "," : "") "\"" jesc(changelog[i]) "\""
346
+ out = out "]"
305
347
  # waves[]: branches grouped by `### ` subheading, in document order. A plan
306
348
  # with no subheadings yields one wave with an empty name.
307
349
  out = out ",\"waves\":["
@@ -311,6 +353,7 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
311
353
  for (i = 1; i <= n_branches; i++) {
312
354
  if (wave_of[i] != w) continue
313
355
  out = out (first ? "" : ",") "{\"branch\":\"" jesc(ordered_b[i]) "\",\"deferred\":" deferred_of[i] \
356
+ ",\"deferred_reason\":\"" jesc(deferred_why[i]) "\"" \
314
357
  ",\"claimed\":\"" jesc(claimed_of[i]) "\"}"
315
358
  first = 0
316
359
  }
@@ -319,6 +362,7 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
319
362
  out = out "]"
320
363
  out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
321
364
  out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
365
+ out = out ",\"design_raw\":\"" jesc(design) "\""
322
366
  out = out ",\"approved_raw\":\"" jesc(approved) "\""
323
367
  out = out ",\"released_raw\":\"" jesc(released) "\""
324
368
  out = out ",\"delivered_raw\":\"" jesc(delivered) "\""
@@ -350,10 +394,15 @@ in_fm {
350
394
  else if (lower ~ /^assignee:/ && fm_assignee == "") fm_assignee = val_after_colon($0)
351
395
  else if (lower ~ /^review:/ && fm_review == "") fm_review = val_after_colon($0)
352
396
  else if (lower ~ /^impl:/ && fm_impl == "") fm_impl = val_after_colon($0)
397
+ else if (lower ~ /^design:/ && fm_design == "") fm_design = val_after_colon($0)
353
398
  else if (lower ~ /^approved:/ && fm_approved == "") fm_approved = val_after_colon($0)
354
399
  else if (lower ~ /^released:/ && fm_released == "") fm_released = val_after_colon($0)
355
400
  else if (lower ~ /^delivered:/ && fm_delivered == "") fm_delivered = val_after_colon($0)
356
401
  else if (lower ~ /^started:/ && fm_started == "") fm_started = val_after_colon($0)
402
+ # A scalar `changelog:` in front matter is one entry. A YAML list is NOT read
403
+ # here: front matter in this repo is a flat key/value surface, and guessing a
404
+ # list grammar the format never promised would invent a contract.
405
+ else if (lower ~ /^changelog:/ && fm_changelog == "") fm_changelog = val_after_colon($0)
357
406
  next
358
407
  }
359
408
  # Interior of multi-line HTML comments is non-content (template guidance
@@ -391,6 +440,10 @@ in_comment {
391
440
  # section in prose, and those later headings are illustration, not contract.
392
441
  else if ($0 ~ /^## Branches/) { section = branches_seen ? "" : "branches"; branches_seen = 1 }
393
442
  else if ($0 ~ /^## Approval/) section = "approval"
443
+ # First `## Changelog` wins, for the same reason `## Branches` does: a plan
444
+ # about the plan format quotes the section in prose, and the later heading is
445
+ # illustration rather than contract.
446
+ else if ($0 ~ /^## Changelog/) { section = changelog_seen ? "" : "changelog"; changelog_seen = 1 }
394
447
  else section = ""
395
448
  next
396
449
  }
@@ -403,6 +456,7 @@ section == "status" {
403
456
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
404
457
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
405
458
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
459
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = val_after_colon($0)
406
460
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = val_after_colon($0)
407
461
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = val_after_colon($0)
408
462
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = val_after_colon($0)
@@ -417,6 +471,47 @@ section == "approval" {
417
471
  if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && canon_assignee == "") canon_assignee = val_after_colon($0)
418
472
  next
419
473
  }
474
+ # `## Changelog` — the release-note entries. One entry per bullet, wrapped
475
+ # continuation lines folded into the entry they belong to.
476
+ #
477
+ # A bullet opens an entry. An indented non-bullet line continues the open one.
478
+ # A blank line or a line at column zero closes it: the flush-left prose that 8
479
+ # plans use for a "Board impact:" note is a remark to a reviewer, not a release
480
+ # note, so it ends the entry rather than joining it.
481
+ section == "changelog" {
482
+ # A line that is nothing but an HTML comment is not content, at any
483
+ # indentation. Multi-line comments are already swallowed upstream; an INDENTED
484
+ # single-liner would otherwise land in the continuation branch below and paste
485
+ # its own markup into the entry above it.
486
+ if ($0 ~ /^[ \t]*<!--.*-->[ \t]*$/) next
487
+ # An INDENTED bullet is a sub-point of the entry above, not a release note of
488
+ # its own, so it continues rather than opens. No changelog in the repo nests
489
+ # today; the rule is here because the alternative silently promotes a
490
+ # sub-point to a headline the moment one does.
491
+ if (cl_open && $0 ~ /^[ \t]+[-*][ \t]/) {
492
+ _n = $0
493
+ sub(/^[ \t]*[-*][ \t]+/, "", _n)
494
+ changelog[n_changelog] = changelog[n_changelog] " " trim(_n)
495
+ next
496
+ }
497
+ if ($0 ~ /^[ \t]*[-*][ \t]/) {
498
+ _e = $0
499
+ sub(/^[ \t]*[-*][ \t]+/, "", _e)
500
+ _e = strip_placeholder(trim(_e))
501
+ if (_e != "") { changelog[++n_changelog] = _e; cl_open = 1 }
502
+ else cl_open = 0 # an unfilled bullet opens nothing to continue
503
+ next
504
+ }
505
+ if (cl_open && $0 ~ /^[ \t]+[^ \t]/) {
506
+ changelog[n_changelog] = changelog[n_changelog] " " trim($0)
507
+ next
508
+ }
509
+ # Blank line, or prose at column zero: whatever entry was open is finished.
510
+ # The flag matters — without it a continuation-shaped line after a break would
511
+ # glue itself onto an entry it never belonged to.
512
+ cl_open = 0
513
+ next
514
+ }
420
515
  section == "branches" {
421
516
  # `### <name>` opens a wave. Branches before any subheading belong to an
422
517
  # unnamed wave, so a pre-wave plan parses as exactly one wave.
@@ -435,6 +530,30 @@ section == "branches" {
435
530
  sub(/[ \t]*-->.*$/, "", _c)
436
531
  claim_note = trim(_c)
437
532
  }
533
+ # THE REASON FOR THE DEFERRAL, and not merely the fact of one.
534
+ #
535
+ # `<!-- deferred: verified already implemented 2026-08-17 — startRepair() at
536
+ # fleet.ts:806 -->` was tested for presence and the sentence after the colon
537
+ # was dropped on the floor. So the board could say `deferred` beside `no
538
+ # commits` and never say that the first is the REASON for the second — a
539
+ # reader with no access to the plan file saw a branch nobody started and no
540
+ # statement that nobody should.
541
+ #
542
+ # Extracted the same way and in the same place as the claim note, for the same
543
+ # reason: `match()` in the branch loop below clobbers RSTART/RLENGTH, so
544
+ # anything read from the whole line must be read before it runs.
545
+ #
546
+ # Newlines are already impossible here — awk hands this rule one line, and the
547
+ # documented contract is that an annotation binds to the line carrying the
548
+ # branch name. A deferral whose text is wrapped onto a continuation line is
549
+ # not seen, exactly as `deferred` itself was not.
550
+ defer_note = ""
551
+ if (index($0, "deferred:") > 0) {
552
+ _d = $0
553
+ sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
554
+ sub(/[ \t]*-->.*$/, "", _d)
555
+ defer_note = trim(_d)
556
+ }
438
557
  line = $0
439
558
  while (match(line, branch_re)) {
440
559
  b = substr(line, RSTART + 1, RLENGTH - 2)
@@ -442,7 +561,19 @@ section == "branches" {
442
561
  if (n_waves == 0) { wave_names[++n_waves] = "" }
443
562
  wave_of[n_branches] = n_waves
444
563
  wave_seq[n_branches] = ++wave_count[n_waves]
445
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred:/) ? "true" : "false"
564
+ # THE FLAG accepts the bare form as well as the annotated one.
565
+ #
566
+ # It matched `deferred:` only, so `<!-- deferred -->` — the annotation with
567
+ # nothing after it — read as NOT deferred at all: the strongest statement a
568
+ # plan can make about a branch, dropped for want of a colon. A reader
569
+ # writing the shorter form has said the branch will not be built; the
570
+ # parser now hears it.
571
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
572
+ # The reason travels with the flag. Empty on every non-deferred branch, and
573
+ # empty is also the honest answer for the bare form: the branch IS deferred
574
+ # and no reason was recorded, which is a different statement from a reason
575
+ # of "".
576
+ deferred_why[n_branches] = defer_note
446
577
  # Claim reflection, written by the worker after its ref push succeeds. This
447
578
  # is a reflection, not the claim: git refs remain authoritative.
448
579
  claimed_of[n_branches] = claim_note