@plot-pm/board 0.2.0-rc.0 → 0.2.1-rc.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.2.0-rc.0",
3
+ "version": "0.2.1-rc.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",
@@ -21,7 +21,9 @@
21
21
  "access": "public"
22
22
  },
23
23
  "files": [
24
- "dist/board-server.mjs"
24
+ "dist/board-server.mjs",
25
+ "plot-config.sh",
26
+ "plot-plan-meta.sh"
25
27
  ],
26
28
  "scripts": {
27
29
  "dev": "vite",
@@ -33,9 +35,6 @@
33
35
  "test": "node --test test/*.test.mjs",
34
36
  "test:integration": "pnpm build && vitest run"
35
37
  },
36
- "dependencies": {
37
- "zod": "^4.4.0"
38
- },
39
38
  "devDependencies": {
40
39
  "@radix-ui/react-checkbox": "^1.3.0",
41
40
  "@radix-ui/react-popover": "^1.1.0",
@@ -56,6 +55,7 @@
56
55
  "typescript": "^5.9.0",
57
56
  "vite": "^8.1.0",
58
57
  "vite-plugin-singlefile": "^2.3.0",
59
- "vitest": "^4.1.10"
58
+ "vitest": "^4.1.10",
59
+ "zod": "^4.4.0"
60
60
  }
61
61
  }
package/plot-config.sh ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: read a key from the adopting project's `## Plot Config`.
3
+ # Usage: plot-config.sh get <key> [default]
4
+ # Output: the configured value, or the default (possibly empty). Exit 0 always
5
+ # for `get` — missing file, missing section, and missing key all fall
6
+ # back to the default so callers can rely on the output unconditionally.
7
+ # Designed for small-model consumption: one value on stdout, no interpretation.
8
+ #
9
+ # This is the ONE place that knows where plot configuration lives (currently a
10
+ # `## Plot Config` section in the repo-root CLAUDE.md). Helpers must call this
11
+ # instead of grepping CLAUDE.md themselves, so the storage location/format can
12
+ # evolve without touching every consumer.
13
+ #
14
+ # Grammar accepted inside the section (case-insensitive key, bold optional):
15
+ # - **Plan directory:** docs/plans/
16
+ # - Plan directory: docs/plans/
17
+ # - **Plan directory:** `docs/plans/` (with a backticked value + prose note)
18
+ # - **Branch prefixes:** `idea/` (plans), `feature/`, `bug/` (list + prose)
19
+ # Backticks (markdown decoration) and `(...)` (human prose) are stripped from
20
+ # the value; no documented key's value legitimately contains either. Lines
21
+ # outside the `## Plot Config` section never match (no prose false positives),
22
+ # and neither do HTML-commented example lines.
23
+ #
24
+ # Known keys (see the plot skill's Setup section):
25
+ # Project board | Branch prefixes | Plan directory | Active index |
26
+ # Delivered index | Sprint directory | Plan template | Main branch
27
+ #
28
+ # `Plan template` is a repo-root-relative path to the plan template /plot-idea
29
+ # instantiates; when absent, /plot-idea falls back to the shipped template.
30
+
31
+ set -uo pipefail
32
+
33
+ cmd="${1:?Usage: plot-config.sh get <key> [default]}"
34
+ key="${2:?Usage: plot-config.sh get <key> [default]}"
35
+ default="${3:-}"
36
+
37
+ if [ "$cmd" != "get" ]; then
38
+ echo "plot-config: unknown subcommand '$cmd' (only 'get' is supported)" >&2
39
+ exit 1
40
+ fi
41
+
42
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
43
+ config_file="$root/CLAUDE.md"
44
+
45
+ value=""
46
+ if [ -f "$config_file" ]; then
47
+ # Extract the `## Plot Config` section (case-insensitive, portable awk).
48
+ section=$(awk '
49
+ /^##[[:space:]]/ { in_section = (tolower($0) ~ /^##[[:space:]]+plot config[[:space:]]*$/) ; next }
50
+ in_section { print }
51
+ ' "$config_file")
52
+ # Value extraction. A documented key's value is a path, a prefix list, or an
53
+ # owner/number — none of which legitimately contain backticks or parentheses.
54
+ # So we can uniformly treat backticks as markdown decoration and `(...)` as
55
+ # human prose, stripping both. This tolerates real-world config written like
56
+ # - **Plan directory:** `docs/plans/` (date-prefixed, never moved)
57
+ # - **Branch prefixes:** `idea/` (plans), `feature/`, `bug/`, `docs/`
58
+ # without truncating multi-value lists to their first backtick span.
59
+ value=$(printf '%s\n' "$section" \
60
+ | grep -m1 -iE "^[[:space:]]*[-*]?[[:space:]]*\**${key}[:*]" \
61
+ | sed -E '
62
+ s/^[^:]*:[[:space:]]*//; # drop list marker, bold, "key:"
63
+ s/^\**[[:space:]]*//; # drop leading bold before value
64
+ s/\([^)]*\)//g; # drop parenthetical prose
65
+ s/`//g; # drop markdown backticks
66
+ s/[[:space:]]*,[[:space:]]*/, /g; # normalize list separators
67
+ s/[[:space:]]+/ /g; # collapse internal whitespace
68
+ s/^[[:space:]]+//; s/[[:space:]]+$//') # trim ends
69
+ fi
70
+
71
+ if [ -n "$value" ]; then
72
+ printf '%s\n' "$value"
73
+ else
74
+ printf '%s\n' "$default"
75
+ fi
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: parse plan files into structured JSON.
3
+ # Usage: plot-plan-meta.sh <plan-file>... [--prefixes 'idea|feature|bug|docs|infra']
4
+ # Output: one compact JSON object per input file, one per line (JSON lines).
5
+ # Exit 0 always; parse problems are reported in the JSON, never as a
6
+ # crash. Missing files yield an error object (emitted first).
7
+ # Designed for small-model consumption: structured output, no interpretation.
8
+ #
9
+ # This is the ONE place that knows what a plan file looks like. Anything that
10
+ # needs plan metadata (phase, type, branches, PR numbers) must call this
11
+ # script instead of grepping plan files itself — the parser is the format
12
+ # contract. The contract is specified by example in test/reconcile/fixtures/
13
+ # (one fixture per supported shape) and enforced by test/reconcile/.
14
+ #
15
+ # Accepts many files in one invocation and parses them in a single awk pass —
16
+ # cheap enough to run over a 100-plan repo on every /plot. (The first version
17
+ # spawned a subprocess chain per file; at ~80ms/file that priced the parser
18
+ # out of ambient use.)
19
+ #
20
+ # Two plan formats are recognized:
21
+ #
22
+ # canonical the plan template's `## Status` body section:
23
+ # - **Phase:** Approved
24
+ # - **Type:** feature
25
+ # (bullet, bold, and plain `Phase: ...` variants all accepted)
26
+ #
27
+ # frontmatter YAML front matter at the top of the file:
28
+ # ---
29
+ # status: Approved
30
+ # phase: Approved
31
+ # type: feature
32
+ # ---
33
+ # `status:` is the primary field; `phase:` is reported as the
34
+ # alternate so callers can flag disagreement between the two.
35
+ #
36
+ # Front matter wins when both are present (it is the machine-facing surface).
37
+ # A file with neither is reported as format "none" (pre-plot / legacy plan).
38
+ #
39
+ # Phase values are normalized by scanning whitespace-separated tokens for the
40
+ # first known phase word — so decorated real-world values like
41
+ # "Delivered (2026-06-29) — split done" normalize to "delivered". A non-empty
42
+ # value with no known token normalizes to "UNKNOWN"; an absent field to "NONE".
43
+ #
44
+ # JSON fields:
45
+ # file the path given
46
+ # format canonical | frontmatter | none
47
+ # phase_raw primary phase value as written ("" if absent)
48
+ # phase normalized: draft|approved|delivered|released|rejected|
49
+ # superseded|UNKNOWN|NONE
50
+ # phase_alt_raw secondary value when the file carries two (front matter
51
+ # status: AND phase:), else ""
52
+ # phase_alt normalized phase_alt_raw (NONE when absent)
53
+ # type normalized plan type (feature|bug|docs|infra or "")
54
+ # title plan title: front matter `title:` wins, else the first H1
55
+ # (`# ...`) line, else "" (board-facing display field)
56
+ # sprint sprint slug the plan belongs to (`## Status` `Sprint:` or
57
+ # front matter `sprint:`); "" if absent or an HTML-comment
58
+ # placeholder
59
+ # story story slug the plan belongs to (`## Status` `Story:` or
60
+ # front matter `story:`); "" if absent or a placeholder
61
+ # assignee github handle from the `## Approval` `Assignee:` line or
62
+ # front matter `assignee:`; "" if absent
63
+ # branches branch names from the `## Branches` section (backtick-
64
+ # quoted, matching the known prefixes; sorted, unique)
65
+ # prs PR numbers from `→ #NNN` links in the `## Branches`
66
+ # section (sorted, unique)
67
+ #
68
+ # title/sprint/story/assignee are the board-facing surface (`@plot-pm/board`
69
+ # consumes this script instead of parsing plans itself). Front matter wins over
70
+ # the canonical body for every field, matching the `status:`/`phase:` rule.
71
+
72
+ set -uo pipefail
73
+
74
+ prefixes='idea|feature|bug|docs|infra'
75
+ files=()
76
+ missing=()
77
+ while [ $# -gt 0 ]; do
78
+ case "$1" in
79
+ --prefixes) prefixes="${2:?--prefixes needs a value}"; shift 2 ;;
80
+ -*) echo "plot-plan-meta: unknown flag: $1" >&2; shift ;;
81
+ *)
82
+ if [ -f "$1" ]; then files+=("$1"); else missing+=("$1"); fi
83
+ shift ;;
84
+ esac
85
+ done
86
+
87
+ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
88
+ echo "Usage: plot-plan-meta.sh <plan-file>... [--prefixes '<alternation>']" >&2
89
+ exit 1
90
+ fi
91
+
92
+ for f in ${missing[@]+"${missing[@]}"}; do
93
+ 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":[]}\n' \
94
+ "$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
95
+ done
96
+
97
+ [ ${#files[@]} -gt 0 ] || exit 0
98
+
99
+ awk -v PREFIXES="$prefixes" '
100
+ function jesc(s) {
101
+ gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, "\\t", s)
102
+ return s
103
+ }
104
+ function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
105
+ # Value after the first colon, stripped of bold markers / quotes / space.
106
+ function val_after_colon(s) {
107
+ sub(/^[^:]*:/, "", s); sub(/^\**[ \t]*/, "", s)
108
+ sub(/^"/, "", s); sub(/"$/, "", s)
109
+ return trim(s)
110
+ }
111
+ # Template placeholders like "<!-- optional -->" mean "field absent".
112
+ function strip_placeholder(s) { return (s ~ /^<!--/) ? "" : s }
113
+ # First known phase token wins; NONE if empty; UNKNOWN otherwise.
114
+ function norm_phase(raw, lower, toks, n, i, t) {
115
+ if (raw == "") return "NONE"
116
+ lower = tolower(raw)
117
+ n = split(lower, toks, /[ \t]+/)
118
+ for (i = 1; i <= n; i++) {
119
+ t = toks[i]
120
+ gsub(/^[^a-z]+/, "", t); gsub(/[^a-z-]+$/, "", t)
121
+ if (t ~ /^(draft|approved|delivered|released|rejected|superseded)$/) return t
122
+ if (t == "ready-for-review" || t == "in-review") return "approved"
123
+ }
124
+ return "UNKNOWN"
125
+ }
126
+ function norm_type(raw, lower, toks, n, i, t) {
127
+ if (raw == "") return ""
128
+ lower = tolower(raw)
129
+ n = split(lower, toks, /[ \t]+/)
130
+ for (i = 1; i <= n; i++) {
131
+ t = toks[i]
132
+ gsub(/^[^a-z]+/, "", t); gsub(/[^a-z]+$/, "", t)
133
+ if (t ~ /^(feature|bug|docs|infra)$/) return t
134
+ }
135
+ return ""
136
+ }
137
+ function reset_state() {
138
+ fm_status = ""; fm_phase = ""; fm_type = ""
139
+ fm_title = ""; fm_sprint = ""; fm_story = ""; fm_assignee = ""
140
+ canon_phase = ""; canon_type = ""
141
+ canon_sprint = ""; canon_story = ""; canon_assignee = ""
142
+ h1_title = ""
143
+ in_fm = 0; section = ""
144
+ delete branches; n_branches = 0
145
+ delete prs; n_prs = 0
146
+ }
147
+ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, i, j, out, sorted_b, sorted_p, nb, np) {
148
+ if (fm_status != "" || fm_phase != "") {
149
+ fmt = "frontmatter"
150
+ praw = (fm_status != "") ? fm_status : fm_phase
151
+ palt_raw = (fm_status != "" && fm_phase != "") ? fm_phase : ""
152
+ traw = fm_type
153
+ } else if (canon_phase != "") {
154
+ fmt = "canonical"; praw = canon_phase; palt_raw = ""; traw = canon_type
155
+ } else {
156
+ fmt = "none"; praw = ""; palt_raw = ""; traw = ""
157
+ }
158
+ # Board-facing fields: front matter wins over the canonical body; H1 is the
159
+ # title fallback. Placeholders ("<!-- ... -->") count as absent.
160
+ title = strip_placeholder((fm_title != "") ? fm_title : h1_title)
161
+ sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
162
+ story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
163
+ assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
164
+ # Insertion sort + dedupe (portable: no gawk asort).
165
+ nb = 0
166
+ for (i = 1; i <= n_branches; i++) {
167
+ for (j = 1; j <= nb && sorted_b[j] != branches[i]; j++) ;
168
+ if (j <= nb) continue
169
+ for (j = nb; j >= 1 && sorted_b[j] > branches[i]; j--) sorted_b[j+1] = sorted_b[j]
170
+ sorted_b[j+1] = branches[i]; nb++
171
+ }
172
+ np = 0
173
+ for (i = 1; i <= n_prs; i++) {
174
+ for (j = 1; j <= np && sorted_p[j] != prs[i]+0; j++) ;
175
+ if (j <= np) continue
176
+ for (j = np; j >= 1 && sorted_p[j] > prs[i]+0; j--) sorted_p[j+1] = sorted_p[j]
177
+ sorted_p[j+1] = prs[i]+0; np++
178
+ }
179
+ out = "{\"file\":\"" jesc(cur_file) "\",\"format\":\"" fmt "\""
180
+ out = out ",\"phase_raw\":\"" jesc(praw) "\",\"phase\":\"" norm_phase(praw) "\""
181
+ out = out ",\"phase_alt_raw\":\"" jesc(palt_raw) "\",\"phase_alt\":\"" norm_phase(palt_raw) "\""
182
+ out = out ",\"type\":\"" norm_type(traw) "\""
183
+ out = out ",\"title\":\"" jesc(title) "\",\"sprint\":\"" jesc(sprint) "\""
184
+ out = out ",\"story\":\"" jesc(story) "\",\"assignee\":\"" jesc(assignee) "\""
185
+ out = out ",\"branches\":["
186
+ for (i = 1; i <= nb; i++) out = out (i > 1 ? "," : "") "\"" jesc(sorted_b[i]) "\""
187
+ out = out "],\"prs\":["
188
+ for (i = 1; i <= np; i++) out = out (i > 1 ? "," : "") sorted_p[i]
189
+ out = out "]}"
190
+ print out
191
+ }
192
+ BEGIN { branch_re = "`(" PREFIXES ")/[^`]+`" }
193
+ FNR == 1 {
194
+ if (NR > 1) emit_record()
195
+ reset_state()
196
+ cur_file = FILENAME
197
+ if ($0 ~ /^---[ \t]*$/) { in_fm = 1; next }
198
+ }
199
+ in_fm {
200
+ if ($0 ~ /^---[ \t]*$/) { in_fm = 0; next }
201
+ lower = tolower($0)
202
+ if (lower ~ /^status:/ && fm_status == "") fm_status = val_after_colon($0)
203
+ else if (lower ~ /^phase:/ && fm_phase == "") fm_phase = val_after_colon($0)
204
+ else if (lower ~ /^type:/ && fm_type == "") fm_type = val_after_colon($0)
205
+ else if (lower ~ /^title:/ && fm_title == "") fm_title = val_after_colon($0)
206
+ else if (lower ~ /^sprint:/ && fm_sprint == "") fm_sprint = val_after_colon($0)
207
+ else if (lower ~ /^story:/ && fm_story == "") fm_story = val_after_colon($0)
208
+ else if (lower ~ /^assignee:/ && fm_assignee == "") fm_assignee = val_after_colon($0)
209
+ next
210
+ }
211
+ # First H1 is the title fallback (front matter title: still wins in emit).
212
+ /^#[ \t]/ && h1_title == "" { h1_title = trim(substr($0, 2)) }
213
+ /^## / {
214
+ if ($0 ~ /^## Status/) section = "status"
215
+ else if ($0 ~ /^## Branches/) section = "branches"
216
+ else if ($0 ~ /^## Approval/) section = "approval"
217
+ else section = ""
218
+ next
219
+ }
220
+ section == "status" {
221
+ lower = tolower($0)
222
+ if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
223
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**type[:*]/ && canon_type == "") canon_type = val_after_colon($0)
224
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**sprint[:*]/ && canon_sprint == "") canon_sprint = val_after_colon($0)
225
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**story[:*]/ && canon_story == "") canon_story = val_after_colon($0)
226
+ next
227
+ }
228
+ section == "approval" {
229
+ lower = tolower($0)
230
+ if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && canon_assignee == "") canon_assignee = val_after_colon($0)
231
+ next
232
+ }
233
+ section == "branches" {
234
+ line = $0
235
+ while (match(line, branch_re)) {
236
+ b = substr(line, RSTART + 1, RLENGTH - 2)
237
+ branches[++n_branches] = b
238
+ line = substr(line, RSTART + RLENGTH)
239
+ }
240
+ line = $0
241
+ while (match(line, /→ #[0-9]+/)) {
242
+ p = substr(line, RSTART, RLENGTH)
243
+ gsub(/[^0-9]/, "", p)
244
+ prs[++n_prs] = p
245
+ line = substr(line, RSTART + RLENGTH)
246
+ }
247
+ next
248
+ }
249
+ END { if (NR > 0) emit_record() }
250
+ ' "${files[@]}"