paneltir 0.8.0 → 0.10.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.
@@ -31,6 +31,13 @@ library shared with other projects. Rules:
31
31
  src/dashboard-theme.ts -->. Colour changes go there, never in the kit. The
32
32
  dashboard's **shape** is a `DashboardThemeForm` passed to the same provider
33
33
  (`panel` or `ledger`); it too is this project's choice, not the kit's.
34
+ - **This project's theme goes to `PanelApp` as `extraThemes`, never as
35
+ `themes`.** `extraThemes` offers it alongside the four the kit ships;
36
+ `themes` means "exactly these and nothing else", so passing one theme there
37
+ deletes all four and the settings sheet ends up with a single row. That is
38
+ not hypothetical — it happened to two projects before the distinction
39
+ existed, and it looks like the kit having no themes rather than like a prop
40
+ being wrong.
34
41
  - **The panel's gate is this project's, not the library's.** `middleware.ts`,
35
42
  `lib/session.ts` and `api/login.ts` / `api/logout.ts` / `api/panel-state.ts`
36
43
  were copied here by `npx paneltir init`. They hold this project's password
@@ -38,12 +45,34 @@ library shared with other projects. Rules:
38
45
  change when the kit updates, and `paneltir init` will not overwrite them.
39
46
  The password, the GitHub token and the target repository are environment
40
47
  variables where this project is hosted — never in this repository.
48
+ - **Those copied files do not update, so they have to be asked.** Run
49
+ `npx paneltir doctor` when the panel misbehaves for no visible reason: it
50
+ compares each copied file against the version installed now and separates
51
+ the three answers that need different actions — you edited it, the template
52
+ moved underneath you, or both. The second is the one no `git diff` can find,
53
+ because there is nothing in this project's history to diff against, and it is
54
+ how a fix released upstream stayed out of a project for two releases.
41
55
  - **The panel explains itself once.** New readers get a short guided tour the
42
- first time they open the board; each note is dismissed and stays dismissed,
43
- per browser. **Help** in the header reports what the panel needs — the
44
- password, the GitHub token, the target repository — with what is missing and
45
- the steps to fix it, asked from the server so it reports the truth rather
46
- than a guess. "Show the guide again" in there brings the tour back.
56
+ first time they open the board; each note is dismissed and stays dismissed.
57
+ **Help** in the header reports what the panel needs — the password, the
58
+ GitHub token, the target repository — with what is missing and the steps to
59
+ fix it, asked from the server so it reports the truth rather than a guess.
60
+ "Show the guide again" in there brings the tour back.
61
+ - **A panel nobody has used yet walks its own setup.** On a board with no
62
+ cards and no runs, the panel asks the server what is configured and, if
63
+ something is missing, opens a walkthrough that does one variable at a time
64
+ and checks each rather than asking anyone to tick it. Closing it is
65
+ remembered in this browser, because the board it would otherwise be written
66
+ to is the very thing that cannot be saved yet. On a board already in use it
67
+ is a banner instead — said before a save fails rather than after — and the
68
+ walkthrough waits behind **Help**. Its last screen carries the one thing no
69
+ screen can check for you: `/admin` and its bundle must both refuse a signed
70
+ out visitor.
71
+ - **And it can hand the problem to Claude.** The walkthrough carries a line to
72
+ paste into a session in this repository. It names
73
+ `.claude/skills/panel/SKILL.md`, which `paneltir init` wrote, and that file
74
+ covers every variable the panel checks for — held together by a test, so the
75
+ prompt cannot come to point at a document that no longer answers it.
47
76
  - **Say which version the dashboard runs on.** `paneltirBuild.short` from
48
77
  `paneltir` renders as `vX.Y.Z · <first 12 of the fingerprint>`; keep it visible somewhere in
49
78
  the panel. `npx paneltir version` answers the same question from a terminal,
package/INSTALL.md CHANGED
@@ -19,7 +19,7 @@ identity** — never another project's.
19
19
  npm install paneltir
20
20
 
21
21
  # or, without npm access, straight from the repository:
22
- # npm install github:daifukus/paneltir#v0.8.0
22
+ # npm install github:daifukus/paneltir#v0.10.0
23
23
  ```
24
24
 
25
25
  (Use the tag you were given; never install without pinning a version.)
@@ -39,10 +39,11 @@ identity** — never another project's.
39
39
  not another project's theme.
40
40
 
41
41
  3. **If no palette is defined yet**, do not make up values: `paneltir` ships
42
- base themes that are already designed and tested (`cyberpunkTheme`,
43
- `midnightTheme`, `oldMoneyTheme`, all exported in `THEME_PRESETS`). Ask
44
- which one fits the project best and if the answer is not available right
45
- now, use `midnightTheme`, the most neutral one, and say so explicitly.
42
+ base themes that are already designed and tested `midnightTheme`,
43
+ `oldMoneyTheme`, `cyberpunkTheme` and `claudeTheme`, the only light one, all
44
+ exported in `THEME_PRESETS`. Ask which one fits the project best, and if the
45
+ answer is not available right now use `midnightTheme`, the most neutral one,
46
+ and say so explicitly.
46
47
  Store the decision in a new file (for example `src/dashboard-theme.ts`),
47
48
  re-exporting the chosen preset, or copying its values if they are expected
48
49
  to diverge:
@@ -138,18 +139,33 @@ identity** — never another project's.
138
139
  import 'paneltir/style.css'
139
140
  import { PanelApp } from 'paneltir'
140
141
  import board from './data/panel-state.json'
141
-
142
- <PanelApp state={board} saveEndpoint="/admin/api/panel-state" showHistory />
142
+ import { dashboardTheme } from './dashboard-theme'
143
+
144
+ <PanelApp
145
+ state={board}
146
+ saveEndpoint="/admin/api/panel-state"
147
+ showHistory
148
+ // This project's identity, offered *alongside* the kit's presets. Use
149
+ // `extraThemes` and never `themes`: `themes` is the exact set, so passing
150
+ // one theme there deletes all four presets and the settings sheet ends up
151
+ // with a single row. That happened to two real projects.
152
+ extraThemes={{
153
+ thisProject: { label: 'This Project', tokens: dashboardTheme, note: 'derived from this repository' },
154
+ }}
155
+ />
143
156
  ```
144
157
 
145
158
  That is the whole page. `PanelApp` draws the board, the analysis, the card
146
- sheet, the settings, the guide and the notifications, and it offers the
147
- kit's themes with no further configuration. Everything a project wants
159
+ sheet, the settings, the guide and the notifications, and with no themes
160
+ passed at all it offers the kit's four. Everything a project wants
148
161
  differently is a prop rather than a fork:
149
162
 
150
163
  | Prop | What it changes |
151
164
  | --- | --- |
152
- | `themes`, `defaultTheme` | The identities the settings sheet offers. Defaults to the kit's presets. |
165
+ | `extraThemes` | This project's own identities, offered **as well as** the kit's presets. Theirs come first, so the panel opens in one of them. This is the one you want. |
166
+ | `themes` | The exact set: these and nothing else, presets included. Only for a caller deliberately curating the list — it replaces rather than adds. |
167
+ | `defaultTheme` | Which one to draw before the board has said. Defaults to the first key. |
168
+ | `gate` | `.paneltir/gate.json` from this project, imported. With it the setup list gains a row saying whether the files `init` copied are still the ones this version ships; without it the panel says nothing about them rather than guessing. |
153
169
  | `brand` | What sits where a name goes in the header. Defaults to the board's `project`. |
154
170
  | `capabilities`, `extras` | Sections of the caller's own. Not drawn when absent. |
155
171
  | `links`, `footer`, `decoration`, `className` | Where else to go, what sits under the panel, and anything the caller draws behind it. |
@@ -169,19 +185,59 @@ identity** — never another project's.
169
185
  | `PANEL_FILE` | Where the board lives in it, if not `src/data/panel-state.json`. |
170
186
  | `SESSION_SECRET` | Optional. Defaults to `ADMIN_PASSWORD`, so changing the password ends every session already issued. |
171
187
 
188
+ None of that has to be done from a table. Open the panel on a board nobody
189
+ has worked yet and it walks the variables one at a time, checking each
190
+ against the server rather than asking anyone to tick it off — and if a step
191
+ does not land, it carries a line to paste into a Claude session in this
192
+ repository, which reads the skill `init` just wrote and takes it from
193
+ there. On a board already in use the same walkthrough is behind **Help**,
194
+ offered only while something is actually missing.
195
+
196
+ **The files `init` copied do not update, so ask them.** Everything under
197
+ `api/`, `lib/` and `middleware.ts` is this project's from the moment it is
198
+ copied — that is the point, since it holds this project's password rule —
199
+ and the cost is that a fix released upstream can be missing here while every
200
+ version number on screen looks current. It has happened: the `HTTP 500` on
201
+ saving was fixed in `template/api/panel-state.ts`, and projects installed
202
+ before that still had it.
203
+
204
+ ```bash
205
+ npx paneltir doctor
206
+ ```
207
+
208
+ **Commit `.paneltir/gate.json`.** `init` writes it to record what it copied
209
+ and what it copied it from, and it is provenance rather than a build
210
+ artifact: ignored by git it disappears on the next clone and every answer
211
+ below degrades to "differs", which is the one that cannot say who moved.
212
+
213
+ It separates the three answers that need different actions — *yours* (you
214
+ edited it), *BEHIND* (you did not touch it and the template moved), *BOTH* —
215
+ and the middle one is invisible to `git diff`, because this project's history
216
+ has nothing to compare against. `init --force` replaces the gate and never
217
+ the board; read the diff first where a file is marked BOTH, or the edit is
218
+ lost.
219
+
220
+ ```bash
221
+ npx paneltir run "what this pass did" --cards=a,b
222
+ ```
223
+
224
+ appends an entry to the board's `runs` so the record does not depend on
225
+ anyone remembering at the end of a session. It writes the file and commits
226
+ nothing.
227
+
172
228
  Two things to check before trusting it: `/admin` signed out must land on
173
229
  `/login`, and **the panel's own JavaScript bundle must answer 401 signed
174
230
  out**. If it does not, point the last entry of the matcher in
175
231
  `middleware.ts` at wherever this project's build emits it — protecting the
176
232
  page but not the bundle leaves the board readable by anyone who opens the
177
- HTML.
233
+ HTML. The walkthrough's last screen says both, for the same reason.
178
234
 
179
235
  9. **Record the kit fingerprint.** The kit reports itself, so no one has to
180
236
  read `node_modules` to find out what is installed:
181
237
 
182
238
  ```bash
183
239
  npx paneltir version # version, fingerprint, where it came from
184
- npx paneltir check 0.8.0 # exits non-zero if that is not what is installed
240
+ npx paneltir check 0.10.0 # exits non-zero if that is not what is installed
185
241
  npx paneltir board # read the board and say what is wrong with it
186
242
  ```
187
243
 
package/README.md CHANGED
@@ -54,7 +54,7 @@ tag rather than a branch, so an unfinished push cannot reach a project:
54
54
  npm install paneltir
55
55
 
56
56
  # or, without npm access, straight from the repository:
57
- # npm install github:daifukus/paneltir#v0.8.0
57
+ # npm install github:daifukus/paneltir#v0.10.0
58
58
  ```
59
59
 
60
60
  Installing from Git runs the `prepare` script, which builds `dist/`, so the
package/bin/paneltir.mjs CHANGED
@@ -14,7 +14,8 @@
14
14
  * to the project: readable, editable, in its own git history. Re-running never
15
15
  * overwrites what is already there.
16
16
  */
17
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
17
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
18
+ import { createHash } from 'node:crypto'
18
19
  import { dirname, join, relative, resolve } from 'node:path'
19
20
  import { fileURLToPath, pathToFileURL } from 'node:url'
20
21
 
@@ -110,6 +111,43 @@ function survey(target) {
110
111
  return found
111
112
  }
112
113
 
114
+ /**
115
+ * Records the provenance of the gate now in the project.
116
+ *
117
+ * Merged rather than replaced: re-running `init` writes only the files that
118
+ * were missing, so a stamp that dropped the rest would forget where the
119
+ * majority of the gate came from. Only files this run actually wrote are
120
+ * re-stamped — for one it left alone we did not write it and cannot claim to
121
+ * know its origin, so the previous answer stands or there is none.
122
+ *
123
+ * The board is stamped like the rest and then deliberately ignored by
124
+ * `doctor`: it is meant to diverge from the seed on the first card, and a
125
+ * report that called that drift would be noise on every project that is
126
+ * working.
127
+ */
128
+ function writeStamp(target, templateFiles, written) {
129
+ const previous = readStamp(target)
130
+ const files = { ...(previous?.files ?? {}) }
131
+ for (const file of written) files[file] = sha256(join(target, file))
132
+
133
+ const stamp = {
134
+ version: pkg.version,
135
+ templateHash: fingerprint.templateHash ?? '',
136
+ copiedAt: new Date().toISOString(),
137
+ files,
138
+ }
139
+ try {
140
+ mkdirSync(dirname(join(target, STAMP)), { recursive: true })
141
+ writeFileSync(join(target, STAMP), JSON.stringify(stamp, null, 2) + '\n')
142
+ } catch (error) {
143
+ // Never fatal. The gate is copied and working; the stamp only makes a
144
+ // later report sharper, and failing the install over bookkeeping would
145
+ // trade something that matters for something that does not.
146
+ console.error(`\npaneltir: could not write ${STAMP} (${error.message}).`)
147
+ console.error('The gate is in place; `paneltir doctor` will have less to go on.')
148
+ }
149
+ }
150
+
113
151
  function reportSurvey(found) {
114
152
  const lines = []
115
153
 
@@ -149,6 +187,36 @@ Remove it once the board is moved over, or the project ships both.`)
149
187
  /** The board. Never replaced once it exists — see the loop below. */
150
188
  const BOARD_SEED = 'src/data/panel-state.json'
151
189
 
190
+ /**
191
+ * Where `init` records what it copied and what it copied it from.
192
+ *
193
+ * The gate is the one part of this package that leaves as files a project
194
+ * keeps, and a kept file can never be told it is old. That has been paid for
195
+ * twice: the `HTTP 500` fix landed in `template/api/panel-state.ts`, so every
196
+ * project installed before it still has a save that cannot work, and the skill
197
+ * file is copied the same way, so a rule learned upstream never reaches the
198
+ * Claude that reads it.
199
+ *
200
+ * A plain diff cannot close that, because it cannot tell "you edited this"
201
+ * from "we changed this underneath you" — and those need opposite actions.
202
+ * Hashing each file as written is what separates them.
203
+ */
204
+ const STAMP = '.paneltir/gate.json'
205
+
206
+ function sha256(path) {
207
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
208
+ }
209
+
210
+ function readStamp(target) {
211
+ try {
212
+ return JSON.parse(readFileSync(join(target, STAMP), 'utf8'))
213
+ } catch {
214
+ // Absent or unreadable is not a fault: every project installed before
215
+ // stamping existed is here, and `doctor` still compares the files.
216
+ return null
217
+ }
218
+ }
219
+
152
220
  function init(args) {
153
221
  const force = args.includes('--force')
154
222
  const target = process.cwd()
@@ -180,6 +248,8 @@ function init(args) {
180
248
  written.push(file)
181
249
  }
182
250
 
251
+ writeStamp(target, files, written);
252
+
183
253
  console.log(`paneltir ${pkg.version} — the panel's gate, into ${target}\n`)
184
254
 
185
255
  const notes = reportSurvey(survey(target))
@@ -197,8 +267,20 @@ function init(args) {
197
267
  for (const file of kept) console.log(` = ${file}`)
198
268
  }
199
269
 
270
+ // Written the gate versus found it already there is the honest first-install
271
+ // signal on this side, and it changes what is worth saying: somebody
272
+ // re-running this has read the rest once already.
200
273
  if (!written.length) {
201
274
  console.log('\nNothing to do — every file is already in place.')
275
+ console.log(`
276
+ If the panel still will not save, open it and press Help: it asks the server
277
+ what is actually set and reports whether, never the value. Or ask this
278
+ project's own Claude:
279
+
280
+ Read .claude/skills/panel/SKILL.md and set up the Paneltir panel with me:
281
+ tell me each environment variable it needs, what it is for, where in this
282
+ project to put it, and check the ones already set.
283
+ `)
202
284
  return
203
285
  }
204
286
 
@@ -232,6 +314,17 @@ Then check two things before trusting it:
232
314
  not, adjust the last entry of the matcher in middleware.ts to wherever
233
315
  this project's build emits it — protecting the page but not the bundle
234
316
  leaves the board readable by anyone who opens the HTML.
317
+
318
+ None of that has to be done from here. Open the panel and it walks the
319
+ variables one at a time, checking each against the server rather than asking
320
+ you to tick it. And if a step does not land, the walkthrough carries a line to
321
+ paste into a session in this repository:
322
+
323
+ Read .claude/skills/panel/SKILL.md and set up the Paneltir panel with me:
324
+ tell me each environment variable it needs, what it is for, where in this
325
+ project to put it, and check the ones already set.
326
+
327
+ That skill was just written into this project, and it covers exactly this.
235
328
  `)
236
329
  }
237
330
 
@@ -276,7 +369,7 @@ function check(args) {
276
369
  * neither helps the person who has just edited the file by hand and wants to
277
370
  * know before they push. This is that answer, from the same check.
278
371
  */
279
- function board(args) {
372
+ async function board(args) {
280
373
  const file = args.find((a) => !a.startsWith('-')) || process.env.PANEL_FILE || 'src/data/panel-state.json'
281
374
  const path = resolve(process.cwd(), file)
282
375
 
@@ -286,7 +379,7 @@ function board(args) {
286
379
  process.exit(1)
287
380
  }
288
381
 
289
- const check = readBoard(readFileSync(path, 'utf8'))
382
+ const check = (await boardReader())(readFileSync(path, 'utf8'))
290
383
  if (check.ok) {
291
384
  const cards = check.board.cards.length
292
385
  console.log(`ok ${file} — ${cards} card${cards === 1 ? '' : 's'}, ${check.board.columns.length} columns`)
@@ -305,7 +398,205 @@ function board(args) {
305
398
  process.exit(1)
306
399
  }
307
400
 
308
- const { readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href)
401
+
402
+ /**
403
+ * Says whether the files this project copied are still the files it shipped.
404
+ *
405
+ * The three answers it exists to tell apart:
406
+ *
407
+ * - **yours** — you edited it. Nothing to do; the gate is meant to be edited,
408
+ * and this is only here so the next line is believable.
409
+ * - **behind** — you did not touch it and the template moved. This is the one
410
+ * that has cost real money twice, and the one a bare `git diff` cannot see
411
+ * at all, because there is nothing in the project's history to diff
412
+ * against.
413
+ * - **both** — you edited it *and* the template moved. The only case that
414
+ * needs a person: `--force` would throw your edit away.
415
+ *
416
+ * It reports and never repairs, for the same reason the panel records a request
417
+ * instead of running it: replacing somebody's front door is a decision, and
418
+ * `init --force` is where a person makes it having read what changed.
419
+ */
420
+ function doctor() {
421
+ const target = process.cwd()
422
+ const templateDir = join(packageRoot, 'template')
423
+ if (!existsSync(templateDir)) {
424
+ console.error('paneltir: this install has no template/ directory.')
425
+ process.exit(1)
426
+ }
427
+
428
+ const stamp = readStamp(target)
429
+ const files = walk(templateDir).sort()
430
+
431
+ console.log(`paneltir ${pkg.version} — the gate in ${target}\n`)
432
+
433
+ if (!stamp) {
434
+ console.log(`No ${STAMP}, so this gate was copied before stamping existed.`)
435
+ console.log('Files can still be compared; "you edited it" and "we changed it"')
436
+ console.log('cannot be told apart until the next `init` writes one.\n')
437
+ } else {
438
+ const behind = stamp.templateHash !== (fingerprint.templateHash ?? '')
439
+ console.log(`Copied from v${stamp.version} on ${String(stamp.copiedAt).slice(0, 10)}`)
440
+ console.log(behind ? 'The template has changed since.\n' : 'The template has not changed since.\n')
441
+ }
442
+
443
+ const rows = []
444
+ for (const file of files) {
445
+ // The board is meant to diverge: it stops being a template on the first
446
+ // card. Calling that drift would put a warning on every working project.
447
+ if (file === BOARD_SEED) {
448
+ rows.push([file, existsSync(join(target, file)) ? 'yours' : 'missing', 'the board — never compared'])
449
+ continue
450
+ }
451
+
452
+ const here = join(target, file)
453
+ if (!existsSync(here)) {
454
+ rows.push([file, 'missing', 'never copied; `paneltir init` writes it'])
455
+ continue
456
+ }
457
+
458
+ const mine = sha256(here)
459
+ const theirs = sha256(join(templateDir, file))
460
+ const stamped = stamp?.files?.[file]
461
+
462
+ if (mine === theirs) {
463
+ rows.push([file, 'current', ''])
464
+ } else if (!stamped) {
465
+ rows.push([file, 'differs', 'no stamp, so who moved cannot be said'])
466
+ } else if (mine === stamped) {
467
+ rows.push([file, 'BEHIND', 'you did not touch it; the template moved'])
468
+ } else if (stamped === theirs) {
469
+ rows.push([file, 'yours', 'you edited it; the template has not moved'])
470
+ } else {
471
+ rows.push([file, 'BOTH', 'you edited it AND the template moved — read both'])
472
+ }
473
+ }
474
+
475
+ const width = Math.max(...rows.map(([file]) => file.length))
476
+ for (const [file, state, note] of rows) {
477
+ console.log(` ${state.padEnd(8)} ${file.padEnd(width)}${note ? ` ${note}` : ''}`)
478
+ }
479
+
480
+ const stale = rows.filter(([, state]) => state === 'BEHIND' || state === 'BOTH')
481
+ const missing = rows.filter(([, state]) => state === 'missing')
482
+
483
+ if (!stale.length && !missing.length) {
484
+ console.log('\nNothing to do.')
485
+ return
486
+ }
487
+
488
+ if (stale.length) {
489
+ console.log(`\n${stale.length} file(s) are older than the package they came from.`)
490
+ console.log('See what changed, then decide:')
491
+ console.log(` diff -u <file> node_modules/paneltir/template/<file>`)
492
+ console.log('`paneltir init --force` replaces the gate and never the board.')
493
+ console.log('Anything marked BOTH would lose your edit — read that diff first.')
494
+ }
495
+ if (missing.length) {
496
+ console.log(`\n${missing.length} file(s) were never copied. \`paneltir init\` writes them.`)
497
+ }
498
+ // A gate that cannot work is a failure, not a remark: this runs in CI for
499
+ // some projects, and an exit code is the only part of it a script reads.
500
+ process.exit(1)
501
+ }
502
+
503
+ /**
504
+ * Appends a run to the board, so the record does not depend on remembering.
505
+ *
506
+ * `runs` is what each pass actually did, and until now it was written by hand
507
+ * — by Claude, into JSON, at the end of a session, from memory. That is the
508
+ * shape of record that is complete right up until the session that mattered.
509
+ * A command can be called the moment the work lands.
510
+ *
511
+ * It writes the file and nothing else: the board is committed by the panel's
512
+ * Save or by the project's own commit, and a CLI that pushed on somebody's
513
+ * behalf would be the second thing in this project writing to their
514
+ * repository.
515
+ */
516
+ async function run(args) {
517
+ const flags = args.filter((a) => a.startsWith('--'))
518
+ const plain = args.filter((a) => !a.startsWith('--'))
519
+ const summary = plain[0]
520
+ const file = plain[1] ?? BOARD_SEED
521
+
522
+ if (!summary) {
523
+ console.error('paneltir run "what this pass did" [--cards a,b] [--by you] [board.json]')
524
+ process.exit(1)
525
+ }
526
+
527
+ const path = resolve(process.cwd(), file)
528
+ if (!existsSync(path)) {
529
+ console.error(`paneltir: no board at ${file}`)
530
+ process.exit(1)
531
+ }
532
+
533
+ const text = readFileSync(path, 'utf8')
534
+ const check = (await boardReader())(text)
535
+ if (!check.ok) {
536
+ // Refused before writing, not after: appending to a board that is already
537
+ // broken buries the fault under a change nobody asked for.
538
+ console.error(`paneltir: ${file} is not a board this kit can read, so nothing was appended.\n`)
539
+ for (const problem of check.problems) {
540
+ console.error(problem.at ? ` ${problem.at}\n ${problem.says}` : ` ${problem.says}`)
541
+ }
542
+ process.exit(1)
543
+ }
544
+
545
+ const board = check.board
546
+ const cards = (flags.find((f) => f.startsWith('--cards='))?.slice(8) ?? '')
547
+ .split(',')
548
+ .map((id) => id.trim())
549
+ .filter(Boolean)
550
+
551
+ // A run naming a card the board does not have is a claim that cannot be
552
+ // checked, which is the only thing `cards` was for.
553
+ const known = new Set(board.cards.map((card) => card.id))
554
+ const unknown = cards.filter((id) => !known.has(id))
555
+ if (unknown.length) {
556
+ console.error(`paneltir: ${file} has no card called ${unknown.join(', ')}.`)
557
+ process.exit(1)
558
+ }
559
+
560
+ const by = flags.find((f) => f.startsWith('--by='))?.slice(5) === 'you' ? 'you' : 'claude'
561
+ const date = new Date().toISOString().slice(0, 10)
562
+ board.runs = Array.isArray(board.runs) ? board.runs : []
563
+ board.runs.unshift({
564
+ id: `run-${date}-${String(board.runs.length + 1).padStart(3, '0')}`,
565
+ date,
566
+ by,
567
+ summary,
568
+ cards,
569
+ fingerprint: shortHash,
570
+ })
571
+
572
+ writeFileSync(path, JSON.stringify(board, null, 2) + '\n')
573
+ console.log(`Appended to ${file}: ${summary}`)
574
+ console.log(` ${cards.length ? `cards ${cards.join(', ')}` : 'no cards named'} · by ${by} · ${shortHash}`)
575
+ console.log('\nThe file is changed and not committed. The panel commits it on Save,')
576
+ console.log('or commit it yourself — this writes nothing to your repository.')
577
+ }
578
+
579
+ /**
580
+ * The board reader, loaded only by the commands that read a board.
581
+ *
582
+ * It used to be imported at module scope, which meant every command pulled in
583
+ * `dist/index.js` — and that bundle imports React, because it is a component
584
+ * library. So `paneltir init`, whose entire job is to copy seven files, and
585
+ * `paneltir doctor`, whose job is to hash them, both failed outright in a
586
+ * project that had not installed React yet. Which is exactly when somebody runs
587
+ * `init`.
588
+ *
589
+ * It is the same rule as the gate's, one layer up: a thing whose job is files
590
+ * does not carry a component library to do it. Here the fix is smaller than
591
+ * duplicating anything — the import just moves to where it is used.
592
+ */
593
+ let readBoard
594
+ async function boardReader() {
595
+ if (!readBoard) {
596
+ ;({ readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href))
597
+ }
598
+ return readBoard
599
+ }
309
600
 
310
601
  const [command, ...rest] = process.argv.slice(2)
311
602
 
@@ -320,7 +611,13 @@ switch (command) {
320
611
  check(rest)
321
612
  break
322
613
  case 'board':
323
- board(rest)
614
+ await board(rest)
615
+ break
616
+ case 'doctor':
617
+ doctor()
618
+ break
619
+ case 'run':
620
+ await run(rest)
324
621
  break
325
622
  default:
326
623
  console.log(`paneltir v${pkg.version} · ${shortHash}
@@ -329,6 +626,8 @@ switch (command) {
329
626
  paneltir version what is installed, and where it came from
330
627
  paneltir check [version] fail if the installed version is not the one meant
331
628
  paneltir board [file] read this project's board and say what is wrong
629
+ paneltir doctor is the gate you copied still the gate we ship?
630
+ paneltir run "summary" append what this pass did to the board's runs
332
631
 
333
632
  The components install as a library and are never edited. The gate — password,
334
633
  session, write-back — is copied in by init and belongs to the project.`)