@plot-pm/board 0.6.0-rc.2 → 0.6.0-rc.200

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.2",
3
+ "version": "0.6.0-rc.200",
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
@@ -47,6 +58,13 @@
47
58
  # Tracker plot | jira | github-issues | linear (+ URL)
48
59
  # (plot = plans in this repo ARE the tracker; absent = same)
49
60
  # Git host github | bitbucket (resolves gh vs bb)
61
+ # CI jenkins | github-actions | none — which CI system this
62
+ # project uses. Recorded by /plot-board-setup; not yet
63
+ # read by the board.
64
+ # Jenkins instance the slug or URL passed to a Jenkins CLI's -I flag.
65
+ # Read back by /plot-board-setup to verify auth against
66
+ # the right instance — without it the only runnable
67
+ # check verifies nothing.
50
68
  #
51
69
  # `Plan template` is a repo-root-relative path to the plan template /plot-idea
52
70
  # instantiates; when absent, /plot-idea falls back to the shipped template.
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)
@@ -68,6 +74,27 @@
68
74
  # continuation line is not seen — keep it on the branch line.
69
75
  # prs PR numbers from `→ #NNN` links in the `## Branches`
70
76
  # section (sorted, unique)
77
+ # changelog the plan's `## Changelog` entries, one string per bullet, in
78
+ # document order; [] when the plan has no changelog or an
79
+ # unfilled one. This is the one field that says WHAT A PLAN
80
+ # CHANGES, which title and story do not.
81
+ # ENTRIES, not lines: a bullet wrapped across several lines is
82
+ # one entry with the continuation lines joined by a single
83
+ # space (9 of the 34 changelogs in the origin repo wrap, so
84
+ # line-per-line would have shredded a quarter of them). An
85
+ # INDENTED bullet folds into the entry above it the same way:
86
+ # a sub-point is not a release note of its own.
87
+ # Non-bullet prose in the section is NOT an entry — 8 plans
88
+ # close their changelog with a "Board impact:" paragraph, which
89
+ # is a note to a reviewer rather than a release note. Comment
90
+ # interiors stay non-content, as everywhere else in this parser,
91
+ # so the template's guidance block contributes nothing.
92
+ # issues tracker issue numbers this plan answers, from the `## Status`
93
+ # `Issue:` line or front matter `issue:` (sorted, unique).
94
+ # A DEDICATED field, never a scan of the body for `#NNN`: a
95
+ # body scan cannot tell a signal from a citation, which is the
96
+ # same reason `prs` reads only `→ #NNN`. Accepts a list
97
+ # (`Issue: #226, #228`) because one plan can answer several.
71
98
  # review_raw the plan's review-channel answer as written (`## Status`
72
99
  # `Review:` or front matter `review:`); "" if absent
73
100
  # review normalized: pr|in-session|ballot|UNKNOWN|NONE
@@ -80,6 +107,12 @@
80
107
  # `Released:` or front matter `released:` — date and version).
81
108
  # Empty until /plot-release records it; the tag stays the git
82
109
  # truth and this merely reflects it.
110
+ # design_raw the design transition record as written (`## Status`
111
+ # `Design:` or front matter `design:` — who/when, and what the
112
+ # design work is); "" if absent. Reported for any plan that
113
+ # carries the line, whatever its phase: a plan that WENT
114
+ # THROUGH design keeps the record after moving on, exactly as
115
+ # `approved_raw` outlives the Approved phase.
83
116
  # approved_raw the approval transition record as written (`## Status`
84
117
  # `Approved:` or front matter `approved:` — who/when/channel,
85
118
  # e.g. "2026-07-30, alice, in-session"); "" if absent
@@ -132,7 +165,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
132
165
  fi
133
166
 
134
167
  for f in ${missing[@]+"${missing[@]}"}; do
135
- 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":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
168
+ 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' \
136
169
  "$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
137
170
  done
138
171
 
@@ -160,7 +193,7 @@ function norm_phase(raw, lower, toks, n, i, t) {
160
193
  for (i = 1; i <= n; i++) {
161
194
  t = toks[i]
162
195
  gsub(/^[^a-z]+/, "", t); gsub(/[^a-z-]+$/, "", t)
163
- if (t ~ /^(draft|approved|delivered|released|rejected|superseded)$/) return t
196
+ if (t ~ /^(draft|design|approved|delivered|released|rejected|superseded)$/) return t
164
197
  if (t == "ready-for-review" || t == "in-review") return "approved"
165
198
  }
166
199
  return "UNKNOWN"
@@ -205,11 +238,11 @@ function reset_state() {
205
238
  fm_status = ""; fm_phase = ""; fm_type = ""
206
239
  fm_title = ""; fm_sprint = ""; fm_story = ""; fm_assignee = ""
207
240
  fm_review = ""; fm_impl = ""; fm_approved = ""; fm_started = ""; fm_released = ""
208
- fm_delivered = ""
241
+ fm_delivered = ""; fm_design = ""
209
242
  canon_phase = ""; canon_type = ""
210
243
  canon_sprint = ""; canon_story = ""; canon_assignee = ""
211
244
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
212
- canon_delivered = ""
245
+ canon_delivered = ""; canon_design = ""
213
246
  h1_title = ""
214
247
  # "" means the plan carries no readable round — NOT zero. Emitted by omitting
215
248
  # the field, so a consumer cannot mistake "never interrogated" for "asked
@@ -218,11 +251,15 @@ function reset_state() {
218
251
  in_fm = 0; section = ""; in_comment = 0; in_challenge = 0; branches_seen = 0
219
252
  delete branches; n_branches = 0
220
253
  delete prs; n_prs = 0
254
+ fm_issue = ""; canon_issue = ""
255
+ delete issues; n_issues = 0
221
256
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
222
257
  delete deferred_of; delete claimed_of; delete ordered_b; n_waves = 0
223
258
  delete started; n_started = 0
259
+ fm_changelog = ""
260
+ delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
224
261
  }
225
- function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, approved, delivered, i, j, out, sorted_b, sorted_p, nb, np) {
262
+ 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) {
226
263
  if (fm_status != "" || fm_phase != "") {
227
264
  fmt = "frontmatter"
228
265
  praw = (fm_status != "") ? fm_status : fm_phase
@@ -241,10 +278,25 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
241
278
  assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
242
279
  review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
243
280
  impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
281
+ design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
244
282
  approved = strip_placeholder((fm_approved != "") ? fm_approved : canon_approved)
245
283
  released = strip_placeholder((fm_released != "") ? fm_released : canon_released)
246
284
  delivered = strip_placeholder((fm_delivered != "") ? fm_delivered : canon_delivered)
247
285
  if (fm_started != "" && strip_placeholder(fm_started) != "") started[++n_started] = fm_started
286
+ # `Issue:` names the tracker signals this plan answers, and it is a DEDICATED
287
+ # field rather than a scan of the body for `#NNN`. A body scan cannot tell a
288
+ # signal from a citation: plans in this repo mention PR numbers constantly,
289
+ # and one of them cites `#226`, `#227`, `#228` as history in its Motivation
290
+ # while naming `PR #232` two sections later. That is the same ambiguity `prs`
291
+ # already answered by reading only `-> #NNN` — one field, one meaning.
292
+ #
293
+ # A LIST, because one plan can answer several signals; the plan that
294
+ # introduced this field subsumes three.
295
+ issue = strip_placeholder((fm_issue != "") ? fm_issue : canon_issue)
296
+ while (match(issue, /#[0-9]+/)) {
297
+ issues[++n_issues] = substr(issue, RSTART + 1, RLENGTH - 1)
298
+ issue = substr(issue, RSTART + RLENGTH)
299
+ }
248
300
  # Insertion sort + dedupe (portable: no gawk asort).
249
301
  nb = 0
250
302
  for (i = 1; i <= n_branches; i++) {
@@ -260,6 +312,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
260
312
  for (j = np; j >= 1 && sorted_p[j] > prs[i]+0; j--) sorted_p[j+1] = sorted_p[j]
261
313
  sorted_p[j+1] = prs[i]+0; np++
262
314
  }
315
+ ni = 0
316
+ for (i = 1; i <= n_issues; i++) {
317
+ for (j = 1; j <= ni && sorted_i[j] != issues[i]+0; j++) ;
318
+ if (j <= ni) continue
319
+ for (j = ni; j >= 1 && sorted_i[j] > issues[i]+0; j--) sorted_i[j+1] = sorted_i[j]
320
+ sorted_i[j+1] = issues[i]+0; ni++
321
+ }
263
322
  out = "{\"file\":\"" jesc(cur_file) "\",\"format\":\"" fmt "\""
264
323
  out = out ",\"phase_raw\":\"" jesc(praw) "\",\"phase\":\"" norm_phase(praw) "\""
265
324
  out = out ",\"phase_alt_raw\":\"" jesc(palt_raw) "\",\"phase_alt\":\"" norm_phase(palt_raw) "\""
@@ -270,6 +329,17 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
270
329
  for (i = 1; i <= nb; i++) out = out (i > 1 ? "," : "") "\"" jesc(sorted_b[i]) "\""
271
330
  out = out "],\"prs\":["
272
331
  for (i = 1; i <= np; i++) out = out (i > 1 ? "," : "") sorted_p[i]
332
+ out = out "],\"issues\":["
333
+ for (i = 1; i <= ni; i++) out = out (i > 1 ? "," : "") sorted_i[i]
334
+ out = out "]"
335
+ # Document order, never sorted: a changelog is a narrative sequence, and the
336
+ # first entry is the headline. Front matter wins, as it does for every other
337
+ # field, and contributes exactly one entry.
338
+ if (fm_changelog != "" && strip_placeholder(fm_changelog) != "") {
339
+ delete changelog; n_changelog = 1; changelog[1] = strip_placeholder(fm_changelog)
340
+ }
341
+ out = out ",\"changelog\":["
342
+ for (i = 1; i <= n_changelog; i++) out = out (i > 1 ? "," : "") "\"" jesc(changelog[i]) "\""
273
343
  out = out "]"
274
344
  # waves[]: branches grouped by `### ` subheading, in document order. A plan
275
345
  # with no subheadings yields one wave with an empty name.
@@ -288,6 +358,7 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
288
358
  out = out "]"
289
359
  out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
290
360
  out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
361
+ out = out ",\"design_raw\":\"" jesc(design) "\""
291
362
  out = out ",\"approved_raw\":\"" jesc(approved) "\""
292
363
  out = out ",\"released_raw\":\"" jesc(released) "\""
293
364
  out = out ",\"delivered_raw\":\"" jesc(delivered) "\""
@@ -315,13 +386,19 @@ in_fm {
315
386
  else if (lower ~ /^title:/ && fm_title == "") fm_title = val_after_colon($0)
316
387
  else if (lower ~ /^sprint:/ && fm_sprint == "") fm_sprint = val_after_colon($0)
317
388
  else if (lower ~ /^story:/ && fm_story == "") fm_story = val_after_colon($0)
389
+ else if (lower ~ /^issue:/ && fm_issue == "") fm_issue = val_after_colon($0)
318
390
  else if (lower ~ /^assignee:/ && fm_assignee == "") fm_assignee = val_after_colon($0)
319
391
  else if (lower ~ /^review:/ && fm_review == "") fm_review = val_after_colon($0)
320
392
  else if (lower ~ /^impl:/ && fm_impl == "") fm_impl = val_after_colon($0)
393
+ else if (lower ~ /^design:/ && fm_design == "") fm_design = val_after_colon($0)
321
394
  else if (lower ~ /^approved:/ && fm_approved == "") fm_approved = val_after_colon($0)
322
395
  else if (lower ~ /^released:/ && fm_released == "") fm_released = val_after_colon($0)
323
396
  else if (lower ~ /^delivered:/ && fm_delivered == "") fm_delivered = val_after_colon($0)
324
397
  else if (lower ~ /^started:/ && fm_started == "") fm_started = val_after_colon($0)
398
+ # A scalar `changelog:` in front matter is one entry. A YAML list is NOT read
399
+ # here: front matter in this repo is a flat key/value surface, and guessing a
400
+ # list grammar the format never promised would invent a contract.
401
+ else if (lower ~ /^changelog:/ && fm_changelog == "") fm_changelog = val_after_colon($0)
325
402
  next
326
403
  }
327
404
  # Interior of multi-line HTML comments is non-content (template guidance
@@ -359,6 +436,10 @@ in_comment {
359
436
  # section in prose, and those later headings are illustration, not contract.
360
437
  else if ($0 ~ /^## Branches/) { section = branches_seen ? "" : "branches"; branches_seen = 1 }
361
438
  else if ($0 ~ /^## Approval/) section = "approval"
439
+ # First `## Changelog` wins, for the same reason `## Branches` does: a plan
440
+ # about the plan format quotes the section in prose, and the later heading is
441
+ # illustration rather than contract.
442
+ else if ($0 ~ /^## Changelog/) { section = changelog_seen ? "" : "changelog"; changelog_seen = 1 }
362
443
  else section = ""
363
444
  next
364
445
  }
@@ -368,8 +449,10 @@ section == "status" {
368
449
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**type[:*]/ && canon_type == "") canon_type = val_after_colon($0)
369
450
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**sprint[:*]/ && canon_sprint == "") canon_sprint = val_after_colon($0)
370
451
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**story[:*]/ && canon_story == "") canon_story = val_after_colon($0)
452
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
371
453
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
372
454
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
455
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = val_after_colon($0)
373
456
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = val_after_colon($0)
374
457
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = val_after_colon($0)
375
458
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = val_after_colon($0)
@@ -384,6 +467,47 @@ section == "approval" {
384
467
  if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && canon_assignee == "") canon_assignee = val_after_colon($0)
385
468
  next
386
469
  }
470
+ # `## Changelog` — the release-note entries. One entry per bullet, wrapped
471
+ # continuation lines folded into the entry they belong to.
472
+ #
473
+ # A bullet opens an entry. An indented non-bullet line continues the open one.
474
+ # A blank line or a line at column zero closes it: the flush-left prose that 8
475
+ # plans use for a "Board impact:" note is a remark to a reviewer, not a release
476
+ # note, so it ends the entry rather than joining it.
477
+ section == "changelog" {
478
+ # A line that is nothing but an HTML comment is not content, at any
479
+ # indentation. Multi-line comments are already swallowed upstream; an INDENTED
480
+ # single-liner would otherwise land in the continuation branch below and paste
481
+ # its own markup into the entry above it.
482
+ if ($0 ~ /^[ \t]*<!--.*-->[ \t]*$/) next
483
+ # An INDENTED bullet is a sub-point of the entry above, not a release note of
484
+ # its own, so it continues rather than opens. No changelog in the repo nests
485
+ # today; the rule is here because the alternative silently promotes a
486
+ # sub-point to a headline the moment one does.
487
+ if (cl_open && $0 ~ /^[ \t]+[-*][ \t]/) {
488
+ _n = $0
489
+ sub(/^[ \t]*[-*][ \t]+/, "", _n)
490
+ changelog[n_changelog] = changelog[n_changelog] " " trim(_n)
491
+ next
492
+ }
493
+ if ($0 ~ /^[ \t]*[-*][ \t]/) {
494
+ _e = $0
495
+ sub(/^[ \t]*[-*][ \t]+/, "", _e)
496
+ _e = strip_placeholder(trim(_e))
497
+ if (_e != "") { changelog[++n_changelog] = _e; cl_open = 1 }
498
+ else cl_open = 0 # an unfilled bullet opens nothing to continue
499
+ next
500
+ }
501
+ if (cl_open && $0 ~ /^[ \t]+[^ \t]/) {
502
+ changelog[n_changelog] = changelog[n_changelog] " " trim($0)
503
+ next
504
+ }
505
+ # Blank line, or prose at column zero: whatever entry was open is finished.
506
+ # The flag matters — without it a continuation-shaped line after a break would
507
+ # glue itself onto an entry it never belonged to.
508
+ cl_open = 0
509
+ next
510
+ }
387
511
  section == "branches" {
388
512
  # `### <name>` opens a wave. Branches before any subheading belong to an
389
513
  # unnamed wave, so a pre-wave plan parses as exactly one wave.