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 +21 -0
- package/README.md +162 -0
- package/generate-kanban.mjs +205 -0
- package/lib/adapters.mjs +164 -0
- package/lib/derive-status.mjs +96 -0
- package/lib/override.mjs +162 -0
- package/lib/render.mjs +277 -0
- package/package.json +51 -0
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
|
+
}
|
package/lib/adapters.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/override.mjs
ADDED
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|