@plot-pm/board 0.8.1 → 0.9.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.8.1",
3
+ "version": "0.9.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",
package/plot-config.sh CHANGED
@@ -27,6 +27,12 @@
27
27
  # Project board | Branch prefixes | Plan directory | Active index |
28
28
  # Delivered index | Sprint directory | Story directory | Story index |
29
29
  # Plan template | Main branch | Board command
30
+ # Worktree root where /plot-dispatch puts its worktrees. Read by
31
+ # plot-dispatch.sh; default is the repo's PARENT, which
32
+ # scatters `plot-wt-*` beside the checkout. An absolute
33
+ # path is taken as given; a relative one resolves against
34
+ # the repo root, so `.worktrees` gathers them inside it.
35
+ # The default is kept for repos that never set it.
30
36
  # Worker bound seconds a single prompt run may take in the worker loop
31
37
  # before it is ended and the worker exits (no hop). Read by
32
38
  # plot-worker-loop.sh; default 3600 (~1h), `0` disables it.
@@ -42,6 +48,14 @@
42
48
  # the board finds the registry wherever it was started.
43
49
  # Absent = the default, so a single-checkout project is
44
50
  # unaffected.
51
+ # Worktree root where /plot-dispatch creates fleet worktrees. A relative
52
+ # value resolves against the repo root, an absolute one is
53
+ # taken as given. Absent = the default `repo_root/..` with
54
+ # the `plot-wt-` prefix — today's behaviour, so no existing
55
+ # checkout moves. Under a dedicated root the prefix is
56
+ # dropped: the directory already says these are Plot's. Read
57
+ # only by the CREATION path; every "which worktree holds this
58
+ # branch" read asks `git worktree list` instead.
45
59
  # Agent-runner keys (optional; Plot hardcodes no agent tooling, Principle 5):
46
60
  # Worker command how /plot-dispatch runs an agent headless on a worktree.
47
61
  # `none` = asked, and this repo starts workers by hand —
package/plot-plan-meta.sh CHANGED
@@ -57,6 +57,11 @@
57
57
  # the old shape invited (a second path-shaped token on a line read as a phantom
58
58
  # branch) is structurally impossible.
59
59
  #
60
+ # The old shape is now closed against the same defect from the other side: a
61
+ # claim must be a LIST ITEM that starts with the backticked name, so a branch
62
+ # cited in prose, a blockquote or an HTML comment under `## Branches` is read as
63
+ # the citation it is. See the anchor at `branch_claim_re` below.
64
+ #
60
65
  # Phase values are normalized by scanning whitespace-separated tokens for the
61
66
  # first known phase word — so decorated real-world values like
62
67
  # "Delivered (2026-06-29) — split done" normalize to "delivered". A non-empty
@@ -88,9 +93,14 @@
88
93
  # assignee github handle from the `## Approval` `Assignee:` line or
89
94
  # front matter `assignee:`; "" if absent
90
95
  # branches branch names, sorted and unique, read from EITHER spelling:
91
- # the old `## Branches` section (backtick-quoted in the list
92
- # line, matching the known prefixes) OR the new `## Waves`
93
- # section (`Branch:` in a `### ` heading — see below). Both
96
+ # the old `## Branches` section (a LIST ITEM whose first token
97
+ # is the backtick-quoted name, matching the known prefixes) OR
98
+ # the new `## Waves` section (`Branch:` in a `### ` heading —
99
+ # see below). A backticked branch name anywhere else under
100
+ # `## Branches` — mid-sentence, in a blockquote, in a comment,
101
+ # on a wrapped continuation line — is a CITATION and claims
102
+ # nothing: plans name each other branches to declare
103
+ # dependencies, and doing so must not claim them. Both
94
104
  # spellings emit the same array; a plan carries one or the
95
105
  # other, and the parser reads both so a migration that moves
96
106
  # files one at a time never makes a plan silently empty.
@@ -201,11 +211,18 @@
201
211
  set -uo pipefail
202
212
 
203
213
  prefixes='idea|feature|bug|docs|infra'
214
+ tracker_override='' # set by --tracker; overrides the config read below
215
+ tracker_override_set=0
204
216
  files=()
205
217
  missing=()
206
218
  while [ $# -gt 0 ]; do
207
219
  case "$1" in
208
220
  --prefixes) prefixes="${2:?--prefixes needs a value}"; shift 2 ;;
221
+ # --tracker names the tracker directly, bypassing plot-config.sh. It exists
222
+ # for the contract tests (which parse fixtures outside any repo whose Plot
223
+ # Config could name a tracker) and for a caller that has already resolved
224
+ # the value once. An empty value means "GitHub", the same as no config.
225
+ --tracker) tracker_override="${2-}"; tracker_override_set=1; shift 2 ;;
209
226
  -*) echo "plot-plan-meta: unknown flag: $1" >&2; shift ;;
210
227
  *)
211
228
  if [ -f "$1" ]; then files+=("$1"); else missing+=("$1"); fi
@@ -225,7 +242,33 @@ done
225
242
 
226
243
  [ ${#files[@]} -gt 0 ] || exit 0
227
244
 
228
- awk -v PREFIXES="$prefixes" '
245
+ # Read the tracker config to determine whether tracker-key issue references
246
+ # (`PROJ-123`) are parsed in addition to GitHub's `#N`.
247
+ #
248
+ # THIS IS THE FIRST CONFIGURATION DEPENDENCY THIS SCRIPT HAS — keep it narrow:
249
+ # read ONE key, an unreadable or missing config means GitHub (today's
250
+ # behaviour), and NEVER fail a parse for want of configuration. plot-config.sh
251
+ # exits 0 for all cases and prints an empty string when the key is absent, so a
252
+ # repo with no `## Plot Config` at all still parses exactly as it does today.
253
+ if [ "$tracker_override_set" -eq 1 ]; then
254
+ tracker="$tracker_override"
255
+ else
256
+ script_dir="$(dirname "${BASH_SOURCE[0]}")"
257
+ tracker=$("$script_dir/plot-config.sh" get Tracker 2>/dev/null || true)
258
+ fi
259
+ # The value may carry a URL after the scheme (`jira https://…`), so match the
260
+ # FIRST token, lowercased. Only a tracker whose keys are `LETTERS-digits` —
261
+ # jira, linear — enables the key form; github, github-issues, plot, and absent
262
+ # all keep `#N`-only, unchanged. An unrecognized token stays GitHub too: a
263
+ # guess here would let `WONT-FIX` masquerade as an issue reference and hide a
264
+ # real ticket, which the plan interrogation explicitly rejected.
265
+ tracker_scheme=$(printf '%s' "$tracker" | tr '[:upper:]' '[:lower:]' | awk '{print $1}')
266
+ case "$tracker_scheme" in
267
+ jira|linear) parse_key_issues=1 ;;
268
+ *) parse_key_issues=0 ;;
269
+ esac
270
+
271
+ awk -v PREFIXES="$prefixes" -v PARSE_KEY_ISSUES="$parse_key_issues" '
229
272
  function jesc(s) {
230
273
  gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, "\\t", s)
231
274
  return s
@@ -316,7 +359,7 @@ function reset_state() {
316
359
  fm_changelog = ""
317
360
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
318
361
  }
319
- 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) {
362
+ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, issue2, i, j, v, is_dup, out, sorted_b, sorted_p, sorted_i, nb, np, ni, num_issues, str_issues, n_num_i, n_str_i, issue_is_str) {
320
363
  if (fm_status != "" || fm_phase != "") {
321
364
  fmt = "frontmatter"
322
365
  praw = (fm_status != "") ? fm_status : fm_phase
@@ -350,10 +393,27 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
350
393
  # A LIST, because one plan can answer several signals; the plan that
351
394
  # introduced this field subsumes three.
352
395
  issue = strip_placeholder((fm_issue != "") ? fm_issue : canon_issue)
396
+ # GitHub-style `#N` is parsed everywhere, regardless of tracker.
353
397
  while (match(issue, /#[0-9]+/)) {
354
398
  issues[++n_issues] = substr(issue, RSTART + 1, RLENGTH - 1)
355
399
  issue = substr(issue, RSTART + RLENGTH)
356
400
  }
401
+ # Jira-style `PROJ-123` is parsed ONLY when Tracker: names a non-GitHub
402
+ # tracker (jira, linear). PARSE_KEY_ISSUES is set by the shell before the awk
403
+ # invocation, based on plot-config.sh reading the Tracker key. Missing config
404
+ # defaults to 0 (GitHub behaviour), so this never fires in a repo with no
405
+ # config — the test in this branch proves that (Done-when item 6).
406
+ #
407
+ # THE PATTERN is `[A-Z]+-[0-9]+` anchored to word boundaries by iterating
408
+ # through the string. A greedy match of the whole field would capture only
409
+ # one; the loop mirrors the `#N` extraction above.
410
+ if (PARSE_KEY_ISSUES == 1) {
411
+ issue2 = strip_placeholder((fm_issue != "") ? fm_issue : canon_issue)
412
+ while (match(issue2, /[A-Z][A-Z0-9]*-[0-9]+/)) {
413
+ issues[++n_issues] = substr(issue2, RSTART, RLENGTH)
414
+ issue2 = substr(issue2, RSTART + RLENGTH)
415
+ }
416
+ }
357
417
  # Insertion sort + dedupe (portable: no gawk asort).
358
418
  nb = 0
359
419
  for (i = 1; i <= n_branches; i++) {
@@ -369,13 +429,36 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
369
429
  for (j = np; j >= 1 && sorted_p[j] > prs[i]+0; j--) sorted_p[j+1] = sorted_p[j]
370
430
  sorted_p[j+1] = prs[i]+0; np++
371
431
  }
372
- ni = 0
432
+ # Issues: sort numeric (GitHub) issues first, then string (Jira) keys.
433
+ # Separate into two arrays, sort each, then concatenate.
434
+ n_num_i = 0; n_str_i = 0
373
435
  for (i = 1; i <= n_issues; i++) {
374
- for (j = 1; j <= ni && sorted_i[j] != issues[i]+0; j++) ;
375
- if (j <= ni) continue
376
- for (j = ni; j >= 1 && sorted_i[j] > issues[i]+0; j--) sorted_i[j+1] = sorted_i[j]
377
- sorted_i[j+1] = issues[i]+0; ni++
436
+ v = issues[i]
437
+ if (v ~ /^[0-9]+$/) {
438
+ # Numeric: check for duplicate, then insert sorted.
439
+ is_dup = 0
440
+ for (j = 1; j <= n_num_i; j++) if (num_issues[j] == v + 0) { is_dup = 1; break }
441
+ if (!is_dup) {
442
+ for (j = n_num_i; j >= 1 && num_issues[j] > v + 0; j--) num_issues[j+1] = num_issues[j]
443
+ num_issues[j+1] = v + 0; n_num_i++
444
+ }
445
+ } else {
446
+ # String (Jira key): check for duplicate, then insert sorted.
447
+ is_dup = 0
448
+ for (j = 1; j <= n_str_i; j++) if (str_issues[j] == v) { is_dup = 1; break }
449
+ if (!is_dup) {
450
+ for (j = n_str_i; j >= 1 && str_issues[j] > v; j--) str_issues[j+1] = str_issues[j]
451
+ str_issues[j+1] = v; n_str_i++
452
+ }
453
+ }
378
454
  }
455
+ # Concatenate: numeric first, then string (matches the field ordering the
456
+ # board expects — GitHub issues before Jira keys when both are present).
457
+ ni = 0
458
+ for (i = 1; i <= n_num_i; i++) sorted_i[++ni] = num_issues[i]
459
+ for (i = 1; i <= n_str_i; i++) sorted_i[++ni] = str_issues[i]
460
+ delete issue_is_str
461
+ for (i = 1; i <= n_str_i; i++) issue_is_str[str_issues[i]] = 1
379
462
  out = "{\"file\":\"" jesc(cur_file) "\",\"format\":\"" fmt "\""
380
463
  out = out ",\"phase_raw\":\"" jesc(praw) "\",\"phase\":\"" norm_phase(praw) "\""
381
464
  out = out ",\"phase_alt_raw\":\"" jesc(palt_raw) "\",\"phase_alt\":\"" norm_phase(palt_raw) "\""
@@ -387,7 +470,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
387
470
  out = out "],\"prs\":["
388
471
  for (i = 1; i <= np; i++) out = out (i > 1 ? "," : "") sorted_p[i]
389
472
  out = out "],\"issues\":["
390
- for (i = 1; i <= ni; i++) out = out (i > 1 ? "," : "") sorted_i[i]
473
+ for (i = 1; i <= ni; i++) {
474
+ # Numeric issues output as JSON numbers; string issues (Jira keys) as quoted.
475
+ if (sorted_i[i] in issue_is_str)
476
+ out = out (i > 1 ? "," : "") "\"" jesc(sorted_i[i]) "\""
477
+ else
478
+ out = out (i > 1 ? "," : "") sorted_i[i]
479
+ }
391
480
  out = out "],\"malformed_prs\":["
392
481
  for (i = 1; i <= n_malformed_prs; i++) out = out (i > 1 ? "," : "") "\"" jesc(malformed_prs[i]) "\""
393
482
  out = out "]"
@@ -463,7 +552,26 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
463
552
  # (13), and the offender this exists to catch is a 53-character sentence, so the
464
553
  # line sits well clear of both. Reported, never enforced — a name past it makes
465
554
  # `long_wave_names`, and nothing refuses the plan.
466
- BEGIN { branch_re = "`(" PREFIXES ")/[^`]+`"; LONG_WAVE_NAME_MAX = 40 }
555
+ # A CLAIM IS A LIST ITEM, and the anchor is what says so.
556
+ #
557
+ # This matched a backticked branch name ANYWHERE on a line, so a plan that
558
+ # merely CITED another plan branch under `## Branches` claimed it. Measured on
559
+ # the board 2026-08-23: two branches rendered twice, in two sections, wearing
560
+ # `claimed twice` — and /plot-dispatch would have fanned out a branch the plan
561
+ # does not own. Both second claims were dependency citations, written exactly as
562
+ # a `## Branches` section should write them.
563
+ #
564
+ # Rewording the citations was the old repair. That is a rule an author must
565
+ # remember in the one section where writing branch names is the entire point,
566
+ # and it had already been forgotten twice. Gates over rules: anchoring makes the
567
+ # parser UNABLE to read a citation as a claim.
568
+ #
569
+ # Licensed by a MEASUREMENT, not a preference. Swept across docs/plans/ on
570
+ # 2026-08-27: 259 lines under `## Branches` carry a backticked branch name and
571
+ # all 259 are anchored list items, so the stricter rule drops no real claim. The
572
+ # contract test re-runs that sweep differentially rather than pinning a total —
573
+ # the estate moves weekly, and an absolute number would fail a correct parser.
574
+ BEGIN { branch_claim_re = "^[ \t]*-[ \t]+`(" PREFIXES ")/[^`]+`"; LONG_WAVE_NAME_MAX = 40 }
467
575
  FNR == 1 {
468
576
  if (NR > 1) emit_record()
469
577
  reset_state()
@@ -667,9 +775,19 @@ section == "branches" {
667
775
  sub(/[ \t]*-->.*$/, "", _d)
668
776
  defer_note = trim(_d)
669
777
  }
670
- line = $0
671
- while (match(line, branch_re)) {
672
- b = substr(line, RSTART + 1, RLENGTH - 2)
778
+ # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
779
+ #
780
+ # The old loop walked the line taking every backticked name on it, which is
781
+ # exactly how a citation became a second claim. Anchoring makes a second match
782
+ # impossible by construction, so the loop is gone rather than left as dead
783
+ # scaffolding that reads like several claims per item are still expected.
784
+ #
785
+ # The match now spans `- ` and the backticks, so the name is cut from the
786
+ # first backtick rather than from RSTART: RSTART lands on the indent.
787
+ if (match($0, branch_claim_re)) {
788
+ claim = substr($0, RSTART, RLENGTH)
789
+ b = substr(claim, index(claim, "`") + 1)
790
+ sub(/`$/, "", b)
673
791
  branches[++n_branches] = b
674
792
  if (n_waves == 0) { wave_names[++n_waves] = "" }
675
793
  wave_of[n_branches] = n_waves
@@ -691,7 +809,6 @@ section == "branches" {
691
809
  # is a reflection, not the claim: git refs remain authoritative.
692
810
  claimed_of[n_branches] = claim_note
693
811
  ordered_b[n_branches] = b
694
- line = substr(line, RSTART + RLENGTH)
695
812
  }
696
813
  line = $0
697
814
  # `→ #N` and `→ owner/repo#N` are both annotations: /plot-deliver instructs