portfolio-kanban-generator 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gary Brooks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # tools/kanban — shared per-project Kanban generator + drift-gate
2
+
3
+ One generator, run inside each project repo, that builds a single **self-contained**
4
+ implementation-Kanban board from the project's backlog and an optional content
5
+ override, and a `--check` mode that fails CI if a committed board drifts from a fresh
6
+ generation. This is the shared implementation of portfolio design **Option B+** (one
7
+ generator; boards committed into each repo and published to each repo's own Pages,
8
+ with a per-repo drift-gate), decision **D5** (generator + optional per-project
9
+ content override), and decision **D6** (render stack — see
10
+ [DECISION-render-stack.md](DECISION-render-stack.md)).
11
+
12
+ Published to the public npm registry as
13
+ **[`portfolio-kanban-generator`](https://www.npmjs.com/package/portfolio-kanban-generator)**
14
+ so any project repo can run the drift-gate in CI via `npx` without vendoring the tool.
15
+ The npm package has **zero runtime dependencies** (vanilla Node). The prompt-library
16
+ source repo that hosts this directory stays private; only the npm package is public.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ # run straight from npm — no install, pinned to an exact version (for CI)
22
+ npx portfolio-kanban-generator@1.0.0 --check --dialect auth-table --project my-project
23
+
24
+ # write / refresh the board (run from the project repo root)
25
+ npx portfolio-kanban-generator@1.0.0 --project my-project
26
+ ```
27
+
28
+ Or, from a checkout of this repo (development / the library's own gate):
29
+
30
+ ```bash
31
+ # write / refresh the board (run from the project repo root)
32
+ node path/to/tools/kanban/generate-kanban.mjs --project my-project
33
+
34
+ # drift-gate: exit 1 if the committed board is stale or missing (for CI)
35
+ node path/to/tools/kanban/generate-kanban.mjs --check --project my-project
36
+ ```
37
+
38
+ ### Options
39
+
40
+ | Option | Default | Meaning |
41
+ |---|---|---|
42
+ | `--project <name>` | basename of `--cwd` | project id, used in the heading and board filename |
43
+ | `--backlog <path>` | `docs/backlog.md` | the backlog file |
44
+ | `--override <path>` | `docs/kanban-content.json` | optional ticket-body override |
45
+ | `--adr <dir>` | `docs/adr` | ADR directory, for citation resolution |
46
+ | `--dialect <name>` | `auth-table` | backlog adapter: `auth-table` or `risk-block` |
47
+ | `--board <path>` | `{project}_implementation-kanban_v1.html` | output board |
48
+ | `--title <text>` | `{project} — Implementation Kanban v1` | board heading |
49
+ | `--cwd <dir>` | `process.cwd()` | base directory for all relative paths |
50
+
51
+ `override_path` and `backlog_dialect` are also recorded per project in the library
52
+ `registry.yml` `defaults:` block, so the per-repo gate can be wired consistently.
53
+
54
+ ## Where authority lives
55
+
56
+ The board never authors its own truth. Status is **derived**, never transcribed:
57
+
58
+ - the **backlog** owns which tickets are Done and which are Parked (human decisions);
59
+ - the ticket **dependency graph** (`blockedBy`) owns readiness;
60
+ - **Ready vs Backlog is authored by neither** — it is computed from the two above.
61
+
62
+ Parked is scope, not progress: a parked ticket never becomes Ready however its
63
+ blockers resolve (**scope beats dependency-readiness**). Readiness is never derived
64
+ from `blocks`. This rule is carried over verbatim from the auth-separation exemplar's
65
+ `scripts/sync-kanban-status.mjs`; see [lib/derive-status.mjs](lib/derive-status.mjs).
66
+
67
+ ## Input contract
68
+
69
+ A **canonical ticket** has a header (from the backlog) and an optional body (from the
70
+ override):
71
+
72
+ - Header: `id`, `title`, `type?`, `priority?`, `score?` (0–30), `phase?`,
73
+ `blockedBy[]`, `blocks?[]`, `backlogStatus` (Done | Parked | Ready | Backlog),
74
+ `status` (derived).
75
+ - Body (override only): `description?`, `acceptance[]?`, `spec?`, `adr[]?`
76
+ (each `ADR-nnnn`), `assignee?`.
77
+
78
+ ### Backlog adapters
79
+
80
+ - **auth-table** (default, the shipping path): 7-column Markdown rows
81
+ `| ID | Ticket | Type | Priority | Tier | Blocked by | Status |`. A row is a
82
+ ticket iff its first cell is a backticked id and it has exactly seven cells.
83
+ Status is keyword-classified — **Parked** (tested first) > **Done** > **Ready** >
84
+ else **Backlog** — and `blockedBy` is read from the "Blocked by" cell. Ready/Backlog
85
+ are recomputed from the graph.
86
+ - **risk-block** (scaffold only, pre-classified): templated
87
+ `#### Risk #N: title — Score: N` blocks with a `**Status:**` line mapping
88
+ COMPLETE → Done, IN PROGRESS → In Progress, READY START → Ready, BLOCKED → Backlog.
89
+ Risks have no dependency edges, so the authored status passes through unchanged.
90
+
91
+ An adapter **fails loudly** on a backlog it cannot parse: parsing zero tickets is an
92
+ error, never an empty success.
93
+
94
+ ### Content override (`docs/kanban-content.json`)
95
+
96
+ A JSON object keyed by ticket id whose values carry **only** body fields. Rules,
97
+ enforced (not merely documented):
98
+
99
+ - an override id absent from the backlog is a **hard error**;
100
+ - a backlog id with no override entry is fine (a header-only card);
101
+ - an override entry carrying any header field (`status`, `backlogStatus`,
102
+ `blockedBy`, `title`, `type`, `priority`, `score`, …) is a **hard error**.
103
+
104
+ ## What fails a run or the gate
105
+
106
+ Both modes fail (exit 1) on: an unparseable backlog, an override id absent from the
107
+ backlog, an override carrying header fields, an unresolved ADR citation, or a
108
+ non-deterministic render. `--check` additionally fails on a missing or stale board.
109
+
110
+ ## Layout
111
+
112
+ ```
113
+ tools/kanban/
114
+ generate-kanban.mjs CLI: default writes/refreshes; --check is the drift-gate
115
+ lib/derive-status.mjs status derivation (verbatim rule) + stats recompute
116
+ lib/adapters.mjs auth-table (required) + risk-block (scaffold)
117
+ lib/override.mjs override load/validation, content merge, ADR resolution
118
+ lib/render.mjs self-contained vanilla-JS board renderer + payload I/O
119
+ test/ node:test suites + fixtures (auth-table, risk-block)
120
+ DECISION-render-stack.md D6 measurement and decision
121
+ ```
122
+
123
+ ## Tests
124
+
125
+ ```bash
126
+ cd tools/kanban && node --test
127
+ ```
128
+
129
+ The suite covers the derive-status rule against its truth table, both adapters,
130
+ override validation, ADR resolution, and end-to-end generation / drift / determinism
131
+ over the fixtures. It also runs inside `python tools/check-library.py`
132
+ (check `check_kanban`) and is guarded across the language boundary by
133
+ `tools/tests/test_kanban_generator.py`.
134
+
135
+ ## Publishing (maintainers)
136
+
137
+ This directory is its own npm package (`portfolio-kanban-generator`), independent of
138
+ the private prompt-library root (which is `"private": true` and cannot publish). The
139
+ published tarball is whitelisted to `generate-kanban.mjs`, `lib/`, `README.md`, and
140
+ `LICENSE` — `test/` and its fixtures are excluded. Confirm the contents before a
141
+ release with `npm pack --dry-run`.
142
+
143
+ A release is cut by pushing a tag `kanban-v<version>` that matches the `version` in
144
+ [package.json](package.json); the [`publish-kanban`](../../.github/workflows/publish-kanban.yml)
145
+ workflow then runs the test suite and `npm publish --access public`. It needs a
146
+ repository secret **`NPM_TOKEN`** (an npm automation token for an account that owns the
147
+ package name).
148
+
149
+ ```bash
150
+ # 1. bump tools/kanban/package.json "version", commit, merge to main
151
+ # 2. cut the release
152
+ git tag kanban-v1.0.0
153
+ git push origin kanban-v1.0.0
154
+ ```
155
+
156
+ To publish manually instead (from this directory, logged in to npm):
157
+
158
+ ```bash
159
+ npm test
160
+ npm pack --dry-run # verify the file list
161
+ npm publish --access public
162
+ ```
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-kanban.mjs — the shared per-project Kanban generator (T2) and its
4
+ * per-repo drift-gate (T5).
5
+ *
6
+ * One generator, run inside each project repo. It builds a single self-contained
7
+ * board from the project's backlog (via a pluggable adapter) and an optional
8
+ * content override, deriving every ticket's status from the backlog + dependency
9
+ * graph rather than trusting a hand-maintained column. The board is committed to
10
+ * the repo and published to its Pages site; a CI drift-gate (`--check`) fails if a
11
+ * commit lets the board diverge from a fresh generation — the same fourth-wall
12
+ * guard the exemplar's `npm run verify` provides.
13
+ *
14
+ * USAGE
15
+ * node generate-kanban.mjs [options] # write / refresh the board
16
+ * node generate-kanban.mjs --check [options] # fail (exit 1) if the board is stale
17
+ *
18
+ * OPTIONS (all optional; sensible defaults for a standard project layout)
19
+ * --project <name> project id (default: basename of --cwd)
20
+ * --backlog <path> backlog file (default: docs/backlog.md)
21
+ * --override <path> content override (default: docs/kanban-content.json)
22
+ * --adr <dir> ADR directory (default: docs/adr)
23
+ * --dialect <name> backlog adapter (default: auth-table; or risk-block)
24
+ * --board <path> board file (default: {project}_implementation-kanban_v1.html)
25
+ * --title <text> board heading (default: "{project} — Implementation Kanban v1")
26
+ * --cwd <dir> base directory for all relative paths (default: process.cwd())
27
+ *
28
+ * A run FAILS (exit 1) in either mode on any of: an unparseable backlog (adapter
29
+ * parsed zero tickets), an override id absent from the backlog, an override
30
+ * carrying header fields, an unresolved ADR citation, or a non-deterministic
31
+ * render. `--check` additionally fails on a missing or stale board.
32
+ */
33
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
34
+ import { basename, resolve } from 'node:path';
35
+ import { parseBacklog } from './lib/adapters.mjs';
36
+ import { deriveAll, computeStats, IN_FLIGHT } from './lib/derive-status.mjs';
37
+ import { loadOverride, validateOverride, mergeContent, resolveAdrCitations } from './lib/override.mjs';
38
+ import { renderBoard, extractPayload } from './lib/render.mjs';
39
+
40
+ const FIXED = '1970-01-01 00:00:00Z'; // stand-in timestamp for the determinism self-check
41
+
42
+ export function parseArgs(argv) {
43
+ const opts = { check: false };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '--check') opts.check = true;
47
+ else if (a.startsWith('--')) opts[a.slice(2)] = argv[++i];
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ function stamp() {
53
+ return new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z');
54
+ }
55
+
56
+ /**
57
+ * The board is always written with LF, but a committed board can arrive with CRLF
58
+ * (a repo with core.autocrlf, or an editor). The drift-gate compares CONTENT, not
59
+ * line-ending encoding, so both sides are normalised before comparison.
60
+ */
61
+ function normalizeEol(s) {
62
+ return s.replace(/\r\n/g, '\n');
63
+ }
64
+
65
+ /**
66
+ * Build the derived tickets and stats from the current inputs. Returns everything
67
+ * the caller needs to render, plus any validation problems (empty = clean).
68
+ */
69
+ function build(paths) {
70
+ const backlogText = readFileSync(paths.backlog, 'utf8');
71
+ const { tickets: header, graphDerived } = parseBacklog(backlogText, paths.dialect); // throws (fail loud) on unparseable / empty
72
+
73
+ const override = loadOverride(paths.override);
74
+ const problems = validateOverride(override, header.map((t) => t.id));
75
+
76
+ let derived;
77
+ if (graphDerived) {
78
+ // Graph-derived dialect: Ready/Backlog are recomputed. Preserve a human-set
79
+ // In Progress / In Review sourced from the committed board.
80
+ const prior = new Map();
81
+ if (existsSync(paths.board)) {
82
+ for (const t of extractPayload(readFileSync(paths.board, 'utf8'), 'payload-tickets')) {
83
+ if (IN_FLIGHT.has(t.status)) prior.set(t.id, t.status);
84
+ }
85
+ }
86
+ derived = deriveAll(header, prior);
87
+ } else {
88
+ // Pre-classified dialect: the adapter authored the final status; pass through.
89
+ derived = header.map((t) => ({ ...t }));
90
+ }
91
+ const tickets = mergeContent(derived, override);
92
+ const stats = computeStats(tickets);
93
+ problems.push(...resolveAdrCitations(tickets, paths.adr));
94
+
95
+ return { tickets, stats, problems };
96
+ }
97
+
98
+ function render(paths, tickets, stats, generatedAt) {
99
+ return renderBoard({ project: paths.project, title: paths.title, tickets, stats, generatedAt });
100
+ }
101
+
102
+ /** Concise human diff between a committed board and a freshly derived one. */
103
+ function describeDrift(committedHtml, tickets, stats) {
104
+ const lines = [];
105
+ try {
106
+ const wasTickets = extractPayload(committedHtml, 'payload-tickets');
107
+ const wasById = new Map(wasTickets.map((t) => [t.id, t]));
108
+ const nowIds = new Set(tickets.map((t) => t.id));
109
+ for (const t of tickets) {
110
+ const was = wasById.get(t.id);
111
+ if (!was) lines.push(`[ticket] ${t.id} is new in the backlog and absent from the board`);
112
+ else if (was.status !== t.status) lines.push(`[status] ${t.id}: ${was.status} -> ${t.status}`);
113
+ }
114
+ for (const t of wasTickets) if (!nowIds.has(t.id)) lines.push(`[ticket] ${t.id} is on the board but gone from the backlog`);
115
+ const wasStats = extractPayload(committedHtml, 'payload-stats');
116
+ if (JSON.stringify(wasStats.byStatus) !== JSON.stringify(stats.byStatus)) {
117
+ lines.push(`[stats] byStatus ${JSON.stringify(wasStats.byStatus)} -> ${JSON.stringify(stats.byStatus)}`);
118
+ }
119
+ } catch {
120
+ lines.push('[board] committed board payloads could not be read for a detailed diff');
121
+ }
122
+ return lines;
123
+ }
124
+
125
+ export function run(argv, env = {}) {
126
+ const log = env.log || ((s) => console.log(s));
127
+ const err = env.err || ((s) => console.error(s));
128
+ const opts = parseArgs(argv);
129
+ const cwd = resolve(opts.cwd || env.cwd || process.cwd());
130
+ const project = opts.project || basename(cwd);
131
+ const paths = {
132
+ project,
133
+ title: opts.title,
134
+ dialect: opts.dialect || 'auth-table',
135
+ backlog: resolve(cwd, opts.backlog || 'docs/backlog.md'),
136
+ override: resolve(cwd, opts.override || 'docs/kanban-content.json'),
137
+ adr: resolve(cwd, opts.adr || 'docs/adr'),
138
+ board: resolve(cwd, opts.board || `${project}_implementation-kanban_v1.html`),
139
+ };
140
+
141
+ let result;
142
+ try {
143
+ result = build(paths);
144
+ } catch (e) {
145
+ err(`kanban: FAILED — ${e.message}`);
146
+ return 1;
147
+ }
148
+ const { tickets, stats, problems } = result;
149
+
150
+ // Determinism self-check: identical inputs must render byte-identical output
151
+ // (generatedAt excluded by holding it fixed). Guards against accidental
152
+ // nondeterministic ordering before anything is written or gated.
153
+ if (render(paths, tickets, stats, FIXED) !== render(paths, tickets, stats, FIXED)) {
154
+ err('kanban: FAILED — render is non-deterministic on unchanged inputs');
155
+ return 1;
156
+ }
157
+
158
+ for (const p of problems) log(` [content] ${p}`);
159
+ if (problems.length) {
160
+ err(`\nkanban: FAILED — ${problems.length} content problem(s); the board was not ${opts.check ? 'gated' : 'written'}.`);
161
+ return 1;
162
+ }
163
+
164
+ const committed = existsSync(paths.board) ? readFileSync(paths.board, 'utf8') : null;
165
+
166
+ if (opts.check) {
167
+ if (committed === null) {
168
+ err(`kanban: FAILED — no committed board at ${paths.board}; run the generator and commit it.`);
169
+ return 1;
170
+ }
171
+ const committedAt = extractPayload(committed, 'payload-stats').generatedAt;
172
+ const fresh = render(paths, tickets, stats, committedAt); // reuse timestamp -> excluded from diff
173
+ if (normalizeEol(fresh) !== normalizeEol(committed)) {
174
+ for (const line of describeDrift(committed, tickets, stats)) log(` ${line}`);
175
+ err(`\nkanban: FAILED — ${basename(paths.board)} is out of step with the backlog. Regenerate and commit.`);
176
+ return 1;
177
+ }
178
+ log(`kanban: in sync — ${tickets.length} ticket(s), ${statusSummary(stats)}.`);
179
+ return 0;
180
+ }
181
+
182
+ // Write mode: idempotent. If the only thing that would change is the timestamp,
183
+ // leave the board untouched so re-running never churns the diff.
184
+ if (committed !== null) {
185
+ const committedAt = extractPayload(committed, 'payload-stats').generatedAt;
186
+ if (normalizeEol(render(paths, tickets, stats, committedAt)) === normalizeEol(committed)) {
187
+ log(`kanban: already in sync — nothing to write (${tickets.length} ticket(s)).`);
188
+ return 0;
189
+ }
190
+ }
191
+ const drift = committed ? describeDrift(committed, tickets, stats) : [];
192
+ writeFileSync(paths.board, render(paths, tickets, stats, stamp()));
193
+ for (const line of drift) log(` ${line}`);
194
+ log(`kanban: ${committed ? 'updated' : 'created'} ${basename(paths.board)} — ${tickets.length} ticket(s), ${statusSummary(stats)}.`);
195
+ return 0;
196
+ }
197
+
198
+ function statusSummary(stats) {
199
+ return Object.entries(stats.byStatus).map(([k, v]) => `${v} ${k}`).join(' / ');
200
+ }
201
+
202
+ // CLI entry (only when run directly, not when imported by tests).
203
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('generate-kanban.mjs')) {
204
+ process.exit(run(process.argv.slice(2)));
205
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Backlog adapters: turn a project's human-authored backlog into header tickets.
3
+ *
4
+ * An adapter is pluggable and MUST fail loudly on a backlog it cannot parse — a
5
+ * silently-empty board is the failure mode this whole tool exists to prevent, so
6
+ * "parsed zero tickets" is an error, never an empty success.
7
+ *
8
+ * A header ticket carries only what the backlog authors:
9
+ * { id, title, type?, priority?, score?, phase?, blockedBy[], backlogStatus,
10
+ * inFlight? }
11
+ * Body fields (description, acceptance, spec, adr, assignee) come from the
12
+ * optional content override, never from the backlog.
13
+ *
14
+ * `backlogStatus` is one of Done | Parked | Ready | Backlog.
15
+ *
16
+ * A dialect is either GRAPH-DERIVED or PRE-CLASSIFIED:
17
+ * - graph-derived (auth-table): the backlog authors Done/Parked but NOT
18
+ * Ready/Backlog; those are recomputed from the dependency graph by
19
+ * derive-status.mjs. This is the shipping path.
20
+ * - pre-classified (risk-block): the backlog authors the whole status directly
21
+ * (a risk has no dependency edges, so "BLOCKED" is a human decision, not a
22
+ * graph property). Such an adapter emits a final `status` and the generator
23
+ * passes it through without graph recomputation.
24
+ */
25
+
26
+ /** Pull every backticked `TOKEN` out of a table cell, in order. */
27
+ function backtickedIds(cell) {
28
+ return [...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
29
+ }
30
+
31
+ /**
32
+ * auth-table (REQUIRED, the shipping dialect).
33
+ *
34
+ * Reads 7-column Markdown rows shaped exactly:
35
+ * | ID | Ticket | Type | Priority | Tier | Blocked by | Status |
36
+ * A line is a ticket row iff its first cell is a single backticked id AND the row
37
+ * has exactly seven cells — which keeps us clear of closure-record tables, prose
38
+ * tables, and the summary tables that share the file.
39
+ *
40
+ * Status is keyword-classified, not matched exactly, because the Status cell
41
+ * carries dates, ADR references and explanatory clauses alongside the word.
42
+ * Parked is tested FIRST so "Parked (..., see ADR-0005)" cannot be misread as
43
+ * anything else; then Done, then Ready, else Backlog. blockedBy is read from the
44
+ * "Blocked by" cell (cell 6). Tier (cell 5) is informational and not carried:
45
+ * it duplicates Priority and is not a canonical ticket field.
46
+ *
47
+ * `phase` (D7) is derived structurally, not from a cell: the backlog groups its
48
+ * ticket tables under "Phase N" section headings (e.g. "### Phase 3 — Core
49
+ * implementation"). Scanning top-to-bottom, the most recent such heading sets the
50
+ * phase for every ticket row beneath it, until the next heading resets it. Rows
51
+ * before any Phase heading get no phase. `phase` is a backlog-owned header field,
52
+ * so it comes from here and never from the content override.
53
+ */
54
+ export function authTable(text) {
55
+ const tickets = [];
56
+ let phase; // current phase from the most recent "Phase N" heading; unset before the first
57
+ for (const line of text.split(/\r?\n/)) {
58
+ const phaseHeading = line.match(/^#{1,4}\s+Phase\s+(\d+)\b/);
59
+ if (phaseHeading) {
60
+ phase = Number(phaseHeading[1]);
61
+ continue;
62
+ }
63
+ // Fast reject: first cell must open with a backticked id.
64
+ if (!/^\|\s*`[^`]+`\s*\|/.test(line)) continue;
65
+ const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
66
+ if (cells.length !== 7) continue;
67
+
68
+ const idMatch = cells[0].match(/^`([^`]+)`$/);
69
+ if (!idMatch) continue; // first cell is more than a bare id -> not a ticket row
70
+ const id = idMatch[1];
71
+
72
+ const statusCell = cells[6];
73
+ const backlogStatus = /\bParked\b/.test(statusCell)
74
+ ? 'Parked'
75
+ : /\bDone\b/.test(statusCell)
76
+ ? 'Done'
77
+ : /\bReady\b/.test(statusCell)
78
+ ? 'Ready'
79
+ : 'Backlog';
80
+
81
+ const emptyish = (s) => s === '' || s === '-' || s === '—'; // '' | '-' | em dash
82
+ tickets.push({
83
+ id,
84
+ title: cells[1],
85
+ ...(emptyish(cells[2]) ? {} : { type: cells[2] }),
86
+ ...(emptyish(cells[3]) ? {} : { priority: cells[3] }),
87
+ ...(phase !== undefined ? { phase } : {}),
88
+ blockedBy: emptyish(cells[5]) ? [] : backtickedIds(cells[5]),
89
+ backlogStatus,
90
+ });
91
+ }
92
+ return tickets;
93
+ }
94
+
95
+ /**
96
+ * risk-block (SCAFFOLD ONLY, PRE-CLASSIFIED — not the shipping path).
97
+ *
98
+ * Reads a templated risk backlog of the shape:
99
+ * #### Risk #3: Some risk title — Score: 21
100
+ * **Status:** IN PROGRESS
101
+ * Ids are synthesised as RISK-<N>. The authored status maps straight to a final
102
+ * column (no graph recomputation — a risk has no dependency edges):
103
+ * COMPLETE -> Done
104
+ * IN PROGRESS -> In Progress
105
+ * READY START -> Ready
106
+ * BLOCKED -> Backlog
107
+ * `backlogStatus` (the canonical four-value field) is set to the nearest of
108
+ * Done | Ready | Backlog for schema completeness.
109
+ *
110
+ * Scaffold status: parsing and mapping are implemented and unit-tested, but this
111
+ * dialect carries no phase and no dependency edges, so its boards are header-only
112
+ * queues. The drift-gate and every migrated repo use auth-table.
113
+ */
114
+ export function riskBlock(text) {
115
+ const tickets = [];
116
+ const re = /^####\s+Risk\s+#(\d+):\s*(.+?)\s*[—-]\s*Score:\s*(\d+)\s*$/gm;
117
+ let m;
118
+ while ((m = re.exec(text)) !== null) {
119
+ const [, n, title, score] = m;
120
+ // The Status line is the first **Status:** after this heading.
121
+ const statusLine = text.slice(re.lastIndex).match(/^\s*\*\*Status:\*\*\s*(.+)$/m);
122
+ const word = statusLine ? statusLine[1].trim().toUpperCase() : '';
123
+ let status = 'Backlog';
124
+ let backlogStatus = 'Backlog';
125
+ if (/\bCOMPLETE\b/.test(word)) (status = 'Done'), (backlogStatus = 'Done');
126
+ else if (/\bIN\s+PROGRESS\b/.test(word)) (status = 'In Progress'), (backlogStatus = 'Ready');
127
+ else if (/\bREADY\s+START\b/.test(word)) (status = 'Ready'), (backlogStatus = 'Ready');
128
+ else if (/\bBLOCKED\b/.test(word)) (status = 'Backlog'), (backlogStatus = 'Backlog');
129
+ tickets.push({ id: `RISK-${n}`, title, score: Number(score), blockedBy: [], backlogStatus, status });
130
+ }
131
+ return tickets;
132
+ }
133
+
134
+ /**
135
+ * Adapter registry. `graphDerived` decides whether derive-status.mjs recomputes
136
+ * Ready/Backlog from the dependency graph (true) or the adapter's authored
137
+ * `status` is passed through unchanged (false).
138
+ */
139
+ export const ADAPTERS = {
140
+ 'auth-table': { parse: authTable, graphDerived: true },
141
+ 'risk-block': { parse: riskBlock, graphDerived: false },
142
+ };
143
+
144
+ /**
145
+ * Run the named adapter and enforce the fail-loudly contract.
146
+ * @returns {{tickets:Array, graphDerived:boolean}}
147
+ * @throws if the dialect is unknown or the adapter parses zero tickets.
148
+ */
149
+ export function parseBacklog(text, dialect) {
150
+ const adapter = ADAPTERS[dialect];
151
+ if (!adapter) {
152
+ throw new Error(
153
+ `unknown backlog dialect '${dialect}' — expected one of: ${Object.keys(ADAPTERS).join(', ')}`,
154
+ );
155
+ }
156
+ const tickets = adapter.parse(text);
157
+ if (tickets.length === 0) {
158
+ throw new Error(
159
+ `backlog dialect '${dialect}' parsed zero tickets — the backlog is unparseable or empty; ` +
160
+ 'a silently-empty board is never an acceptable success.',
161
+ );
162
+ }
163
+ return { tickets, graphDerived: adapter.graphDerived };
164
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Status derivation and stats recomputation for the shared Kanban generator.
3
+ *
4
+ * This is the load-bearing rule, carried over VERBATIM from the auth-separation
5
+ * exemplar's scripts/sync-kanban-status.mjs (see that file's header for the full
6
+ * rationale and the two hand-closure traps that produced it). The single change
7
+ * here is generalisation: the exemplar hard-codes `AUTH-` ids and reads a board
8
+ * payload; this module takes plain data so any project's backlog can drive it.
9
+ *
10
+ * WHERE AUTHORITY LIVES — unchanged from the exemplar:
11
+ * the backlog owns which tickets are Done and which are Parked (human
12
+ * decisions, classified by the adapter).
13
+ * the content graph owns each ticket's dependency edges (blockedBy).
14
+ * Ready vs Backlog is authored by NEITHER — it is derived from the two above.
15
+ *
16
+ * PARKED is scope, not progress. A parked ticket never derives to Ready however
17
+ * its dependencies resolve, because dependency-readiness and being-in-scope are
18
+ * different questions. SCOPE BEATS DEPENDENCY-READINESS.
19
+ *
20
+ * Readiness is a property of the whole graph and must be computed, never
21
+ * transcribed, and it is NEVER derived from `blocks` — only from `blockedBy`.
22
+ */
23
+
24
+ /**
25
+ * Column order, left to right. Parked sits after Done, outside the
26
+ * Backlog -> Done flow, because it is not a stage of that flow: parked tickets
27
+ * are out of scope, not queued. This order is mirrored by the board renderer.
28
+ */
29
+ export const COLUMNS = ['Backlog', 'Ready', 'In Progress', 'In Review', 'Done', 'Parked'];
30
+
31
+ /** Statuses a human sets deliberately (on the board, or in an in-flight-aware
32
+ * backlog dialect); derivation preserves them rather than overwriting them. */
33
+ export const IN_FLIGHT = new Set(['In Progress', 'In Review']);
34
+
35
+ /**
36
+ * Derive one ticket's column.
37
+ *
38
+ * @param {{id:string, blockedBy:string[]}} ticket
39
+ * @param {Set<string>} done ids the backlog marks Done
40
+ * @param {Set<string>} parked ids the backlog marks Parked
41
+ * @param {string|undefined} prior the ticket's previous board status, used only
42
+ * to preserve a human-set In Progress / In Review; anything else is ignored.
43
+ * @returns {string} one of COLUMNS
44
+ */
45
+ export function deriveStatus(ticket, done, parked, prior) {
46
+ if (done.has(ticket.id)) return 'Done';
47
+ // Scope beats dependency-readiness: an out-of-scope ticket is not startable
48
+ // no matter what its blockers have done.
49
+ if (parked.has(ticket.id)) return 'Parked';
50
+ // A human moved this card into an in-flight lane; never clobber that.
51
+ if (IN_FLIGHT.has(prior)) return prior;
52
+ // Readiness is computed from the graph, never transcribed, and only from
53
+ // blockedBy — never from `blocks`.
54
+ return ticket.blockedBy.every((d) => done.has(d)) ? 'Ready' : 'Backlog';
55
+ }
56
+
57
+ /**
58
+ * Apply {@link deriveStatus} across a ticket list, returning a new list with each
59
+ * ticket's `status` set. Input order is preserved (determinism).
60
+ *
61
+ * @param {Array} tickets header tickets carrying id, blockedBy, backlogStatus
62
+ * @param {Map<string,string>} [prior] id -> previous board status (in-flight preservation)
63
+ */
64
+ export function deriveAll(tickets, prior = new Map()) {
65
+ const done = new Set(tickets.filter((t) => t.backlogStatus === 'Done').map((t) => t.id));
66
+ const parked = new Set(tickets.filter((t) => t.backlogStatus === 'Parked').map((t) => t.id));
67
+ return tickets.map((t) => ({ ...t, status: deriveStatus(t, done, parked, prior.get(t.id)) }));
68
+ }
69
+
70
+ /**
71
+ * Recompute the stats payload from derived tickets. Field order is fixed so the
72
+ * serialised JSON is deterministic. `generatedAt` is intentionally excluded here
73
+ * and stamped by the caller — it is the one field excluded from the drift diff.
74
+ *
75
+ * byStatus follows COLUMN order and omits empty columns. byPhase / byPriority /
76
+ * byType tally only tickets that carry that field (no `undefined` bucket).
77
+ */
78
+ export function computeStats(tickets) {
79
+ const tally = (key) => {
80
+ const acc = {};
81
+ for (const t of tickets) {
82
+ const v = t[key];
83
+ if (v === undefined || v === null) continue;
84
+ acc[v] = (acc[v] ?? 0) + 1;
85
+ }
86
+ return acc;
87
+ };
88
+ const statusCounts = tally('status');
89
+ return {
90
+ total: tickets.length,
91
+ byStatus: Object.fromEntries(COLUMNS.filter((c) => statusCounts[c]).map((c) => [c, statusCounts[c]])),
92
+ byPhase: tally('phase'),
93
+ byPriority: tally('priority'),
94
+ byType: tally('type'),
95
+ };
96
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Content override: the optional, per-project body of each ticket.
3
+ *
4
+ * The backlog owns a ticket's header (id, title, type, priority, score, phase,
5
+ * dependency edges, status). The override — docs/kanban-content.json by default —
6
+ * owns only its BODY: the prose a card expands to. Keeping them apart is decision
7
+ * D5: one shared generator, plus an optional per-project content file, so a board
8
+ * can carry rich acceptance criteria without forking the generator.
9
+ *
10
+ * The split is enforced, not merely documented:
11
+ * - an override entry for an id the backlog does not list is a HARD ERROR (a
12
+ * card body with no card is always a mistake — a typo'd id, or content left
13
+ * behind when a ticket was renamed);
14
+ * - a backlog id with no override entry is FINE (a header-only card);
15
+ * - an override entry may carry ONLY body fields. Any header field
16
+ * (status, backlogStatus, blockedBy, title, type, priority, score, phase,
17
+ * blocks, id) in the override is a hard error: it would let content quietly
18
+ * contradict the backlog, which is exactly the drift this tool prevents.
19
+ */
20
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
21
+
22
+ /** The only keys an override entry may carry. */
23
+ export const BODY_FIELDS = ['description', 'acceptance', 'spec', 'adr', 'assignee'];
24
+ const BODY_SET = new Set(BODY_FIELDS);
25
+ const ADR_ID_RE = /^ADR-\d{4}$/;
26
+
27
+ /** Load and JSON-parse the override file. Absent file -> {} (overrides optional). */
28
+ export function loadOverride(path) {
29
+ if (!existsSync(path)) return {};
30
+ let raw;
31
+ try {
32
+ raw = readFileSync(path, 'utf8');
33
+ } catch (e) {
34
+ throw new Error(`override: cannot read ${path}: ${e.message}`);
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(raw);
39
+ } catch (e) {
40
+ throw new Error(`override: ${path} is not valid JSON: ${e.message}`);
41
+ }
42
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
43
+ throw new Error(`override: ${path} must be a JSON object keyed by ticket id`);
44
+ }
45
+ return parsed;
46
+ }
47
+
48
+ /**
49
+ * Validate the override against the set of backlog ids. Returns a list of problem
50
+ * strings (empty = clean); the caller decides that any problem fails the run.
51
+ */
52
+ export function validateOverride(override, backlogIds) {
53
+ const ids = new Set(backlogIds);
54
+ const problems = [];
55
+ for (const [id, body] of Object.entries(override)) {
56
+ if (!ids.has(id)) {
57
+ problems.push(`override names ${id}, which is not a ticket in the backlog`);
58
+ continue;
59
+ }
60
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
61
+ problems.push(`override entry ${id} must be an object of body fields`);
62
+ continue;
63
+ }
64
+ for (const key of Object.keys(body)) {
65
+ if (!BODY_SET.has(key)) {
66
+ problems.push(
67
+ `override entry ${id} carries non-body field '${key}' — the override may hold only ` +
68
+ `${BODY_FIELDS.join(', ')} (header fields are owned by the backlog)`,
69
+ );
70
+ }
71
+ }
72
+ if ('acceptance' in body && !(Array.isArray(body.acceptance) && body.acceptance.every((a) => typeof a === 'string'))) {
73
+ problems.push(`override entry ${id}: acceptance must be an array of strings`);
74
+ }
75
+ if ('adr' in body) {
76
+ if (!Array.isArray(body.adr)) {
77
+ problems.push(`override entry ${id}: adr must be an array of ADR-nnnn ids`);
78
+ } else {
79
+ for (const ref of body.adr) {
80
+ if (typeof ref !== 'string' || !ADR_ID_RE.test(ref)) {
81
+ problems.push(`override entry ${id}: adr entry '${ref}' must match ADR-nnnn (four digits)`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ /**
91
+ * Merge body fields onto header tickets, and compute `blocks` as the reverse of
92
+ * the blockedBy graph. `blocks` is DISPLAY-ONLY: derive-status.mjs never reads it,
93
+ * so populating it cannot affect readiness. Output field order is fixed for
94
+ * deterministic serialisation.
95
+ */
96
+ export function mergeContent(headerTickets, override) {
97
+ // Reverse edges: b in blocks[a] iff a in blockedBy[b].
98
+ const blocks = new Map(headerTickets.map((t) => [t.id, []]));
99
+ for (const t of headerTickets) {
100
+ for (const dep of t.blockedBy) {
101
+ if (blocks.has(dep)) blocks.get(dep).push(t.id);
102
+ }
103
+ }
104
+ return headerTickets.map((t) => {
105
+ const body = override[t.id] ?? {};
106
+ const out = {
107
+ id: t.id,
108
+ title: t.title,
109
+ ...(t.type !== undefined ? { type: t.type } : {}),
110
+ ...(t.priority !== undefined ? { priority: t.priority } : {}),
111
+ ...(t.score !== undefined ? { score: t.score } : {}),
112
+ ...(t.phase !== undefined ? { phase: t.phase } : {}),
113
+ blockedBy: t.blockedBy,
114
+ blocks: blocks.get(t.id),
115
+ backlogStatus: t.backlogStatus,
116
+ status: t.status,
117
+ ...(body.description !== undefined ? { description: body.description } : {}),
118
+ ...(body.acceptance !== undefined ? { acceptance: body.acceptance } : {}),
119
+ ...(body.spec !== undefined ? { spec: body.spec } : {}),
120
+ ...(body.adr !== undefined ? { adr: body.adr } : {}),
121
+ ...(body.assignee !== undefined ? { assignee: body.assignee } : {}),
122
+ };
123
+ return out;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Resolve every ADR citation to a file in the ADR directory. A citation is any
129
+ * `ADR-nnnn` appearing anywhere in a ticket (adr[] list, spec note, description).
130
+ * Ported from the exemplar's validate-kanban-content.mjs check 2: it catches
131
+ * typos, renumbering, and deletions.
132
+ *
133
+ * Returns a list of problem strings. If the ADR directory is absent, citations
134
+ * are a problem (they point nowhere); no citations and no directory is clean.
135
+ */
136
+ export function resolveAdrCitations(tickets, adrDir) {
137
+ const cited = new Map(); // ADR-nnnn -> [ticket ids]
138
+ for (const t of tickets) {
139
+ for (const ref of new Set(JSON.stringify(t).match(/ADR-\d{4}/g) ?? [])) {
140
+ if (!cited.has(ref)) cited.set(ref, []);
141
+ cited.get(ref).push(t.id);
142
+ }
143
+ }
144
+ if (cited.size === 0) return [];
145
+
146
+ const adrFiles = existsSync(adrDir)
147
+ ? readdirSync(adrDir).filter((f) => /^\d{4}-.*\.md$/.test(f))
148
+ : null;
149
+ const problems = [];
150
+ for (const [ref, ids] of cited) {
151
+ const number = ref.slice(4); // strip "ADR-"
152
+ const exists = adrFiles && adrFiles.some((f) => f.startsWith(number));
153
+ if (!exists) {
154
+ problems.push(
155
+ adrFiles === null
156
+ ? `${ids.join(', ')} cite ${ref}, but no ADR directory exists at ${adrDir}/`
157
+ : `${ids.join(', ')} cite ${ref}, which has no file in ${adrDir}/`,
158
+ );
159
+ }
160
+ }
161
+ return problems;
162
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Self-contained board renderer.
3
+ *
4
+ * D6 decision (recorded in tools/kanban/DECISION-render-stack.md): the board is a
5
+ * single HTML file with a vanilla-JS renderer reading two embedded JSON payloads.
6
+ * No framework, no build step, no CDN, no vendored files — the exemplar's
7
+ * React + ~2.8MB babel-standalone stack could not satisfy the "one self-contained
8
+ * board" contract without inlining megabytes of transpiler into every repo's
9
+ * committed board. The status logic ported to vanilla with no rewrite.
10
+ *
11
+ * The two payload script blocks (`payload-tickets`, `payload-stats`) are the
12
+ * board's data of record and the anchor the drift-gate reads back, so their
13
+ * shape and ids match the exemplar exactly.
14
+ */
15
+
16
+ export const PAYLOAD_IDS = ['payload-tickets', 'payload-stats'];
17
+
18
+ /** Extract and parse an embedded JSON payload. Throws if the block is absent. */
19
+ export function extractPayload(html, id) {
20
+ const m = html.match(new RegExp(`id="${id}" type="application/json">([\\s\\S]*?)</script>`));
21
+ if (!m) throw new Error(`no <script id="${id}"> payload found in board`);
22
+ return JSON.parse(m[1]);
23
+ }
24
+
25
+ /**
26
+ * Serialise a payload for embedding. `<` is escaped to its JSON unicode form so a
27
+ * ticket body containing "</script>" cannot break out of the script block; the
28
+ * result is still valid JSON and still deterministic.
29
+ */
30
+ function embed(value) {
31
+ return JSON.stringify(value).replace(/</g, '\\u003c');
32
+ }
33
+
34
+ // The renderer that ships inside every board. Kept as a plain string so the
35
+ // generator has no runtime dependency on a bundler. It reads the two payloads and
36
+ // builds the DOM; every ticket field except id/title/status is treated as
37
+ // optional so header-only cards (no override) render cleanly.
38
+ const BOARD_SCRIPT = String.raw`
39
+ (function () {
40
+ var TICKETS = JSON.parse(document.getElementById('payload-tickets').textContent);
41
+ var STATS = JSON.parse(document.getElementById('payload-stats').textContent);
42
+ var COLUMNS = ['Backlog', 'Ready', 'In Progress', 'In Review', 'Done', 'Parked'];
43
+ var PRIORITY_ORDER = { P0: 0, P1: 1, P2: 2, P3: 3 };
44
+ var byId = {};
45
+ TICKETS.forEach(function (t) { byId[t.id] = t; });
46
+
47
+ function esc(s) {
48
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
49
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
50
+ });
51
+ }
52
+ function priClass(p) { return p ? 'tag-' + String(p).toLowerCase() : ''; }
53
+ function el(html) { var d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; }
54
+
55
+ var state = { phase: 'all', priority: 'all', type: 'all', search: '' };
56
+
57
+ function passes(t) {
58
+ if (state.phase !== 'all' && String(t.phase) !== state.phase) return false;
59
+ if (state.priority !== 'all' && t.priority !== state.priority) return false;
60
+ if (state.type !== 'all' && t.type !== state.type) return false;
61
+ var s = state.search.trim().toLowerCase();
62
+ if (s && [t.id, t.title, t.type, t.phase].join(' ').toLowerCase().indexOf(s) === -1) return false;
63
+ return true;
64
+ }
65
+
66
+ function cardHtml(t) {
67
+ var unmet = (t.blockedBy || []).filter(function (d) { return !(byId[d] && byId[d].status === 'Done'); });
68
+ var tags = '';
69
+ if (t.priority) tags += '<span class="tag ' + priClass(t.priority) + '">' + esc(t.priority) + '</span>';
70
+ if (t.type) tags += '<span class="tag tag-type">' + esc(t.type) + '</span>';
71
+ if (unmet.length) tags += '<span class="tag tag-blocked">Blocked: ' + unmet.length + '</span>';
72
+ var sub = [];
73
+ if (t.phase !== undefined) sub.push('Phase ' + esc(t.phase));
74
+ if (t.score !== undefined) sub.push('Score ' + esc(t.score));
75
+ return '<div class="card ' + (t.priority ? 'border-' + String(t.priority).toLowerCase() : '') + '" data-id="' + esc(t.id) + '">' +
76
+ '<div class="card-id">' + esc(t.id) + '</div>' +
77
+ '<div class="card-title">' + esc(t.title) + '</div>' +
78
+ (sub.length ? '<div class="card-feature">' + sub.join(' · ') + '</div>' : '') +
79
+ '<div class="card-tags">' + tags + '</div>' +
80
+ ((t.blockedBy && t.blockedBy.length) ? '<div class="card-deps">Blocked by: ' + esc(t.blockedBy.join(', ')) + '</div>' : '') +
81
+ '</div>';
82
+ }
83
+
84
+ function renderBoard() {
85
+ var shown = TICKETS.filter(passes);
86
+ var cols = document.getElementById('board');
87
+ cols.innerHTML = '';
88
+ COLUMNS.forEach(function (col) {
89
+ var items = shown.filter(function (t) { return t.status === col; }).sort(function (a, b) {
90
+ var p = (PRIORITY_ORDER[a.priority] == null ? 9 : PRIORITY_ORDER[a.priority]) -
91
+ (PRIORITY_ORDER[b.priority] == null ? 9 : PRIORITY_ORDER[b.priority]);
92
+ return p !== 0 ? p : a.id.localeCompare(b.id);
93
+ });
94
+ var body = items.map(cardHtml).join('');
95
+ cols.appendChild(el(
96
+ '<div class="column"><div class="column-head"><span class="name">' + esc(col) +
97
+ '</span><span class="count">' + items.length + '</span></div>' +
98
+ '<div class="column-body">' + body + '</div></div>'
99
+ ));
100
+ });
101
+ document.getElementById('shown-count').textContent = shown.length + ' of ' + TICKETS.length + ' shown';
102
+ Array.prototype.forEach.call(cols.querySelectorAll('.card'), function (c) {
103
+ c.addEventListener('click', function () { openModal(byId[c.getAttribute('data-id')]); });
104
+ });
105
+ }
106
+
107
+ function section(title, inner) { return inner ? '<h3>' + esc(title) + '</h3>' + inner : ''; }
108
+ function depPills(ids, met) {
109
+ if (!ids || !ids.length) return '<em>None</em>';
110
+ return ids.map(function (d) {
111
+ var dep = byId[d];
112
+ var cls = met === undefined ? 'dep-pill' : ('dep-pill ' + ((dep && dep.status === 'Done') ? 'dep-pill-met' : 'dep-pill-unmet'));
113
+ return '<span class="' + cls + '">' + esc(d) + (dep ? ' - ' + esc(dep.title) : '') + '</span>';
114
+ }).join('');
115
+ }
116
+
117
+ function openModal(t) {
118
+ if (!t) return;
119
+ var tags = '';
120
+ if (t.priority) tags += '<span class="tag ' + priClass(t.priority) + '">' + esc(t.priority) + '</span>';
121
+ if (t.type) tags += '<span class="tag tag-type">' + esc(t.type) + '</span>';
122
+ if (t.phase !== undefined) tags += '<span class="tag tag-phase">Phase ' + esc(t.phase) + '</span>';
123
+ var body =
124
+ section('Description', t.description ? '<p>' + esc(t.description) + '</p>' : '') +
125
+ section('Assignee', t.assignee ? '<p>' + esc(t.assignee) + '</p>' : '') +
126
+ '<h3>Status</h3><p>' + esc(t.status) + '</p>' +
127
+ section('Acceptance Criteria', (t.acceptance && t.acceptance.length) ? '<ul>' + t.acceptance.map(function (a) { return '<li>' + esc(a) + '</li>'; }).join('') + '</ul>' : '') +
128
+ section('Spec / Implementation Notes', t.spec ? '<p>' + esc(t.spec) + '</p>' : '') +
129
+ section('ADRs', (t.adr && t.adr.length) ? '<p>' + t.adr.map(esc).join(', ') + '</p>' : '') +
130
+ '<h3>Blocked By (' + ((t.blockedBy || []).length) + ')</h3><div>' + depPills(t.blockedBy, false) + '</div>' +
131
+ '<h3>Blocks (' + ((t.blocks || []).length) + ')</h3><div>' + depPills(t.blocks, undefined) + '</div>';
132
+ var modal = el(
133
+ '<div class="modal-bg"><div class="modal"><div class="modal-head"><div>' +
134
+ '<div class="card-id">' + esc(t.id) + (t.phase !== undefined ? ' · Phase ' + esc(t.phase) : '') + '</div>' +
135
+ '<div style="font-size:18px;font-weight:700;margin-top:4px">' + esc(t.title) + '</div>' +
136
+ '<div style="display:flex;gap:6px;margin-top:8px">' + tags + '</div></div>' +
137
+ '<button class="close-btn">Close</button></div>' +
138
+ '<div class="modal-body">' + body + '</div></div></div>'
139
+ );
140
+ function close() { document.body.removeChild(modal); }
141
+ modal.addEventListener('click', close);
142
+ modal.querySelector('.modal').addEventListener('click', function (e) { e.stopPropagation(); });
143
+ modal.querySelector('.close-btn').addEventListener('click', close);
144
+ document.body.appendChild(modal);
145
+ }
146
+
147
+ function optionList(sel, values, label) {
148
+ values.forEach(function (v) {
149
+ var o = document.createElement('option');
150
+ o.value = v; o.textContent = label ? label(v) : v; sel.appendChild(o);
151
+ });
152
+ }
153
+
154
+ var phases = Array.from(new Set(TICKETS.map(function (t) { return t.phase; }).filter(function (p) { return p !== undefined; }))).sort(function (a, b) { return a - b; });
155
+ var types = Array.from(new Set(TICKETS.map(function (t) { return t.type; }).filter(Boolean))).sort();
156
+ var priorities = Array.from(new Set(TICKETS.map(function (t) { return t.priority; }).filter(Boolean))).sort();
157
+
158
+ if (phases.length) { optionList(document.getElementById('f-phase'), phases, function (p) { return 'Phase ' + p; }); document.getElementById('w-phase').hidden = false; }
159
+ if (priorities.length) { optionList(document.getElementById('f-priority'), priorities); document.getElementById('w-priority').hidden = false; }
160
+ if (types.length) { optionList(document.getElementById('f-type'), types); document.getElementById('w-type').hidden = false; }
161
+
162
+ document.getElementById('f-phase').addEventListener('change', function (e) { state.phase = e.target.value; renderBoard(); });
163
+ document.getElementById('f-priority').addEventListener('change', function (e) { state.priority = e.target.value; renderBoard(); });
164
+ document.getElementById('f-type').addEventListener('change', function (e) { state.type = e.target.value; renderBoard(); });
165
+ document.getElementById('f-search').addEventListener('input', function (e) { state.search = e.target.value; renderBoard(); });
166
+
167
+ renderBoard();
168
+ })();
169
+ `;
170
+
171
+ const STYLE = String.raw`
172
+ :root {
173
+ --bg:#f8fafc; --panel:#fff; --border:#e2e8f0; --text:#1e293b; --muted:#64748b; --accent:#1f3864;
174
+ --p0:#dc2626; --p0-bg:#fee2e2; --p1:#ea580c; --p1-bg:#ffedd5;
175
+ --p2:#2563eb; --p2-bg:#dbeafe; --p3:#6b7280; --p3-bg:#f3f4f6;
176
+ }
177
+ * { box-sizing:border-box; }
178
+ body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
179
+ header { background:var(--accent); color:#fff; padding:14px 24px; }
180
+ header h1 { margin:0; font-size:20px; font-weight:600; }
181
+ header p { margin:4px 0 0; font-size:12px; opacity:.85; }
182
+ .stats { display:flex; gap:16px; padding:12px 24px; background:#fff; border-bottom:1px solid var(--border); flex-wrap:wrap; font-size:12px; }
183
+ .stat { display:flex; align-items:center; gap:6px; }
184
+ .stat .num { font-weight:700; font-size:16px; color:var(--accent); }
185
+ .stat .lbl { color:var(--muted); text-transform:uppercase; letter-spacing:.05em; font-size:10px; }
186
+ .filters { padding:10px 24px; background:#fff; border-bottom:1px solid var(--border); display:flex; gap:12px; align-items:center; flex-wrap:wrap; font-size:12px; }
187
+ .filters label { color:var(--muted); margin-right:4px; }
188
+ .filters select, .filters input { padding:4px 8px; border:1px solid var(--border); border-radius:4px; font-size:12px; background:#fff; }
189
+ .board { display:flex; gap:12px; padding:16px 24px; overflow-x:auto; min-height:calc(100vh - 200px); }
190
+ .column { flex:1; min-width:240px; max-width:320px; background:#f1f5f9; border-radius:8px; border:1px solid var(--border); display:flex; flex-direction:column; }
191
+ .column-head { padding:10px 12px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; }
192
+ .column-head .name { font-weight:600; font-size:13px; }
193
+ .column-head .count { background:var(--accent); color:#fff; font-size:11px; padding:1px 8px; border-radius:10px; font-weight:600; }
194
+ .column-body { padding:8px; flex:1; overflow-y:auto; }
195
+ .card { background:#fff; border:1px solid var(--border); border-radius:6px; padding:10px; margin-bottom:8px; cursor:pointer; transition:all .15s; border-left-width:4px; }
196
+ .card:hover { box-shadow:0 2px 6px rgba(0,0,0,.08); transform:translateY(-1px); }
197
+ .card-id { font-family:'SF Mono',Consolas,monospace; font-size:10px; color:var(--muted); font-weight:600; }
198
+ .card-title { font-size:13px; font-weight:600; margin:4px 0 6px; line-height:1.3; }
199
+ .card-feature { font-size:11px; color:var(--muted); margin-bottom:6px; }
200
+ .card-tags { display:flex; gap:4px; flex-wrap:wrap; }
201
+ .tag { font-size:9px; font-weight:700; padding:2px 6px; border-radius:3px; text-transform:uppercase; letter-spacing:.04em; }
202
+ .tag-p0 { background:var(--p0-bg); color:var(--p0); } .tag-p1 { background:var(--p1-bg); color:var(--p1); }
203
+ .tag-p2 { background:var(--p2-bg); color:var(--p2); } .tag-p3 { background:var(--p3-bg); color:var(--p3); }
204
+ .tag-type { background:#e0e7ff; color:#3730a3; } .tag-phase { background:#f0fdf4; color:#166534; }
205
+ .tag-blocked { background:#fef3c7; color:#92400e; }
206
+ .card-deps { font-size:10px; color:var(--muted); margin-top:6px; }
207
+ .border-p0 { border-left-color:var(--p0); } .border-p1 { border-left-color:var(--p1); }
208
+ .border-p2 { border-left-color:var(--p2); } .border-p3 { border-left-color:var(--p3); }
209
+ .modal-bg { position:fixed; inset:0; background:rgba(15,23,42,.5); display:flex; align-items:center; justify-content:center; z-index:100; padding:20px; }
210
+ .modal { background:#fff; border-radius:8px; max-width:800px; width:100%; max-height:90vh; overflow-y:auto; box-shadow:0 20px 50px rgba(0,0,0,.3); }
211
+ .modal-head { padding:16px 20px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
212
+ .modal-body { padding:20px; font-size:13px; line-height:1.6; }
213
+ .modal-body h3 { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin:16px 0 6px; font-weight:600; }
214
+ .modal-body p { margin:4px 0 12px; } .modal-body ul { margin:4px 0 12px; padding-left:20px; } .modal-body li { margin:4px 0; }
215
+ .close-btn { background:transparent; border:1px solid var(--border); color:var(--muted); padding:4px 10px; border-radius:4px; cursor:pointer; font-size:12px; }
216
+ .close-btn:hover { background:#f1f5f9; }
217
+ .dep-pill { display:inline-block; background:#f1f5f9; border:1px solid var(--border); font-family:'SF Mono',Consolas,monospace; font-size:11px; padding:2px 6px; border-radius:3px; margin:2px 4px 2px 0; }
218
+ .dep-pill-met { background:#dcfce7; border-color:#86efac; color:#166534; }
219
+ .dep-pill-unmet { background:#fef3c7; border-color:#fcd34d; color:#92400e; }
220
+ `;
221
+
222
+ /**
223
+ * Render a complete, self-contained board document.
224
+ * @param {{project:string, title?:string, tickets:Array, stats:object, generatedAt:string}} opts
225
+ */
226
+ export function renderBoard({ project, title, tickets, stats, generatedAt }) {
227
+ const heading = title || `${project} — Implementation Kanban v1`;
228
+ const statsPayload = { ...stats, generatedAt, version: '1' };
229
+ return `<!DOCTYPE html>
230
+ <html lang="en">
231
+ <head>
232
+ <meta charset="UTF-8" />
233
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
234
+ <title>${escHtml(heading)}</title>
235
+ <!--
236
+ Generated by tools/kanban/generate-kanban.mjs from the project backlog and the
237
+ optional content override. Do NOT hand-edit: a per-repo drift-gate regenerates
238
+ this file and fails CI if a commit diverges from a fresh generation. To change
239
+ the board, change the backlog or docs/kanban-content.json and regenerate.
240
+ Self-contained by design (D6): no framework, no CDN, no vendored files.
241
+ -->
242
+ <style>${STYLE} </style>
243
+ </head>
244
+ <body>
245
+ <header>
246
+ <h1>${escHtml(heading)}</h1>
247
+ <p>Generated from the project backlog. ${stats.total} ticket(s).</p>
248
+ </header>
249
+ <div class="stats" id="stats-bar">
250
+ <div class="stat"><span class="num">${stats.total}</span><span class="lbl">Total</span></div>
251
+ ${COLUMN_STAT_CELLS(stats)} <div class="stat" style="margin-left:auto;color:var(--muted)"><span class="lbl">v${statsPayload.version} · ${escHtml(generatedAt)}</span></div>
252
+ </div>
253
+ <div class="filters">
254
+ <span id="w-phase" hidden><label>Phase:</label><select id="f-phase"><option value="all">All phases</option></select></span>
255
+ <span id="w-priority" hidden><label>Priority:</label><select id="f-priority"><option value="all">All priorities</option></select></span>
256
+ <span id="w-type" hidden><label>Type:</label><select id="f-type"><option value="all">All types</option></select></span>
257
+ <label>Search:</label><input id="f-search" type="text" placeholder="ID, title, type..." style="min-width:200px" />
258
+ <span id="shown-count" style="margin-left:auto;color:var(--muted)"></span>
259
+ </div>
260
+ <div class="board" id="board"></div>
261
+ <script id="payload-tickets" type="application/json">${embed(tickets)}</script>
262
+ <script id="payload-stats" type="application/json">${embed(statsPayload)}</script>
263
+ <script>${BOARD_SCRIPT}</script>
264
+ </body>
265
+ </html>
266
+ `;
267
+ }
268
+
269
+ function COLUMN_STAT_CELLS(stats) {
270
+ return Object.entries(stats.byStatus)
271
+ .map(([c, n]) => ` <div class="stat"><span class="num">${n}</span><span class="lbl">${escHtml(c)}</span></div>\n`)
272
+ .join('');
273
+ }
274
+
275
+ function escHtml(s) {
276
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
277
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "portfolio-kanban-generator",
3
+ "version": "1.0.0",
4
+ "description": "Self-contained per-project implementation-Kanban board generator with a CI drift-gate (--check). Derives every ticket's status from the backlog + dependency graph; zero runtime dependencies (vanilla Node).",
5
+ "type": "module",
6
+ "bin": {
7
+ "generate-kanban": "generate-kanban.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./generate-kanban.mjs",
11
+ "./lib/*": "./lib/*"
12
+ },
13
+ "files": [
14
+ "generate-kanban.mjs",
15
+ "lib/",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test"
24
+ },
25
+ "keywords": [
26
+ "kanban",
27
+ "backlog",
28
+ "board-generator",
29
+ "drift-gate",
30
+ "ci",
31
+ "static-html",
32
+ "zero-dependency",
33
+ "test-automation-portfolio"
34
+ ],
35
+ "author": {
36
+ "name": "Gary Brooks"
37
+ },
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/NeoCognitus70/portfolio-prompts.git",
42
+ "directory": "tools/kanban"
43
+ },
44
+ "homepage": "https://github.com/NeoCognitus70/portfolio-prompts/tree/main/tools/kanban#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/NeoCognitus70/portfolio-prompts/issues"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }