@polderlabs/bizar 4.4.9 → 4.4.11

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.
@@ -178,6 +178,138 @@ export const modsLoader = {
178
178
  mkdirSync(MODS_DIR, { recursive: true });
179
179
  },
180
180
 
181
+ /**
182
+ * v4.4.11 — Validate an installed mod without mounting it.
183
+ *
184
+ * Runs after a copy (in `installFromPath` + `installFromRegistry`) and
185
+ * before the install is reported as successful. Returns a structured
186
+ * `{ ok, errors, warnings }` so callers (the API + the CLI + the
187
+ * provisioner) can surface failures consistently.
188
+ *
189
+ * Checks performed:
190
+ * 1. `mod.json` is valid JSON with required fields (id, name, version).
191
+ * 2. `entry.route` (if declared) is a file that exists and is readable.
192
+ * We pre-parse it with `node --check` to catch syntax errors.
193
+ * 3. Permissions declared in mod.json are validated against the
194
+ * allowed list (delegates to mod-security.mjs).
195
+ * 4. If `entry.validate` is declared, we dynamic-import that file
196
+ * and call its `default` or named `validate` export. The hook can
197
+ * throw to fail the install.
198
+ *
199
+ * Never throws — always returns a structured result.
200
+ */
201
+ async validateModInstallation(id) {
202
+ const errors = [];
203
+ const warnings = [];
204
+ const dir = join(MODS_DIR, id);
205
+ if (!existsSync(dir)) {
206
+ return { ok: false, errors: [`mod directory not found: ${dir}`], warnings };
207
+ }
208
+
209
+ // 1. mod.json valid + required fields
210
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
211
+ if (!manifest || typeof manifest !== 'object') {
212
+ return {
213
+ ok: false,
214
+ errors: ['mod.json is missing or invalid JSON'],
215
+ warnings,
216
+ };
217
+ }
218
+ for (const field of ['id', 'name', 'version']) {
219
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
220
+ errors.push(`mod.json is missing required field "${field}"`);
221
+ }
222
+ }
223
+ if (manifest.id && manifest.id !== id) {
224
+ errors.push(`mod.json "id" field ("${manifest.id}") does not match the directory name ("${id}")`);
225
+ }
226
+
227
+ // 2. entry.route exists + parses
228
+ const entry = manifest.entry || {};
229
+ if (entry.route) {
230
+ const routePath = join(dir, entry.route);
231
+ if (!existsSync(routePath)) {
232
+ errors.push(`entry.route "${entry.route}" does not exist at ${routePath}`);
233
+ } else {
234
+ // Pre-parse the route.mjs with `node --check` to catch syntax
235
+ // errors without executing it. Skipped if node is not available.
236
+ try {
237
+ const { spawnSync } = await import('node:child_process');
238
+ const probe = spawnSync(process.execPath, ['--check', routePath], {
239
+ stdio: ['ignore', 'pipe', 'pipe'],
240
+ timeout: 5000,
241
+ });
242
+ if (probe.status !== 0) {
243
+ // Extract the first error line from the syntax checker
244
+ // output. `node --check` prints something like:
245
+ // /path/to/route.mjs:5
246
+ // syntax is wrong here
247
+ // ^^^
248
+ // SyntaxError: message
249
+ // We want the last "SyntaxError:" line.
250
+ const stderr = (probe.stderr || '').toString();
251
+ const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean);
252
+ const errLine = lines.reverse().find((l) => l.toLowerCase().includes('syntaxerror')) || lines[0] || 'unknown';
253
+ errors.push(`entry.route "${entry.route}" has a syntax error: ${errLine}`);
254
+ }
255
+ } catch {
256
+ // node --check unavailable — skip (not fatal)
257
+ }
258
+ }
259
+ }
260
+
261
+ // 3. Permissions valid
262
+ if (Array.isArray(manifest.permissions)) {
263
+ const { invalid } = parsePermissions(manifest.permissions);
264
+ for (const p of invalid) {
265
+ warnings.push(`mod declares unknown permission: ${p}`);
266
+ }
267
+ }
268
+
269
+ // 4. Optional entry.validate hook
270
+ if (entry.validate) {
271
+ const validatePath = join(dir, entry.validate);
272
+ if (!existsSync(validatePath)) {
273
+ errors.push(`entry.validate "${entry.validate}" does not exist at ${validatePath}`);
274
+ } else {
275
+ try {
276
+ // Dynamic import — the validate hook is opt-in. The mod may
277
+ // export `default` (function) or named export `validate`.
278
+ const mod = await import(/* @vite-ignore */ `file://${validatePath}`);
279
+ const fn = mod.default || mod.validate;
280
+ if (typeof fn !== 'function') {
281
+ warnings.push(
282
+ `entry.validate "${entry.validate}" does not export a function (got ${typeof fn})`,
283
+ );
284
+ } else {
285
+ // Run the validate hook with a 5s timeout. The hook is
286
+ // trusted (it's part of the mod) but we don't want a bad
287
+ // hook to hang the install.
288
+ const result = await Promise.race([
289
+ Promise.resolve()
290
+ .then(() => fn({ mod: manifest, dir }))
291
+ .catch((err) => ({ ok: false, error: err.message })),
292
+ new Promise((resolve) =>
293
+ setTimeout(() => resolve({ ok: false, error: 'validate hook timed out after 5s' }), 5000),
294
+ ),
295
+ ]);
296
+ if (result && result.ok === false) {
297
+ errors.push(`mod validate hook failed: ${result.error || 'unknown'}`);
298
+ }
299
+ }
300
+ } catch (err) {
301
+ errors.push(`failed to load entry.validate "${entry.validate}": ${err.message}`);
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ok: errors.length === 0,
308
+ errors,
309
+ warnings,
310
+ };
311
+ },
312
+
181
313
  /** List all installed mods. v3.3.1 — never throws. */
182
314
  list() {
183
315
  try {
@@ -211,7 +343,7 @@ export const modsLoader = {
211
343
  * Install a mod from a local path. Copies the folder into
212
344
  * `~/.config/bizar/mods/<id>/`. The id is the source folder's basename.
213
345
  */
214
- installFromPath(sourcePath) {
346
+ async installFromPath(sourcePath) {
215
347
  if (!existsSync(sourcePath)) {
216
348
  throw new Error(`source path does not exist: ${sourcePath}`);
217
349
  }
@@ -243,6 +375,24 @@ export const modsLoader = {
243
375
  // (agents/, commands/, skills/). This makes the mod's rules binding
244
376
  // on every agent at session start.
245
377
  installModInstructions(id, target);
378
+ // v4.4.11 — Smoke-test the mod before reporting success. We
379
+ // uninstall on failure so a broken mod doesn't leave a half-
380
+ // copied directory the user has to clean up manually.
381
+ const validation = await this.validateModInstallation(id);
382
+ if (!validation.ok) {
383
+ uninstallModInstructions(id);
384
+ rmSync(target, { recursive: true, force: true });
385
+ const err = new Error(
386
+ `mod "${id}" failed post-install validation: ${validation.errors.join('; ')}`,
387
+ );
388
+ err.validation = validation;
389
+ throw err;
390
+ }
391
+ if (validation.warnings.length > 0) {
392
+ console.warn(
393
+ `[mods-loader] mod "${id}" installed with warnings: ${validation.warnings.join('; ')}`,
394
+ );
395
+ }
246
396
  return loadMod({ id, dir: target });
247
397
  },
248
398
 
@@ -494,6 +644,24 @@ export const modsLoader = {
494
644
  // v3.20 — install mod instructions into the user's opencode config
495
645
  // (agents/, commands/, skills/). Triggered on registry install too.
496
646
  installModInstructions(id, target);
647
+ // v4.4.11 — Smoke-test the mod before reporting success. We
648
+ // uninstall on failure so a broken mod doesn't leave a half-
649
+ // copied directory the user has to clean up manually.
650
+ const validation = await this.validateModInstallation(id);
651
+ if (!validation.ok) {
652
+ uninstallModInstructions(id);
653
+ rmSync(target, { recursive: true, force: true });
654
+ const err = new Error(
655
+ `mod "${id}" failed post-install validation: ${validation.errors.join('; ')}`,
656
+ );
657
+ err.validation = validation;
658
+ throw err;
659
+ }
660
+ if (validation.warnings.length > 0) {
661
+ console.warn(
662
+ `[mods-loader] mod "${id}" installed with warnings: ${validation.warnings.join('; ')}`,
663
+ );
664
+ }
497
665
  return loadMod({ id, dir: target });
498
666
  },
499
667
 
package/cli/bin.mjs CHANGED
@@ -156,10 +156,11 @@ function showInstallHelp() {
156
156
  bizar install — Run the unified BizarHarness installer
157
157
 
158
158
  Usage:
159
- bizar install Install (or refresh) every component
160
- bizar install --dry-run Print what would happen, change nothing
161
- bizar install --force Overwrite existing files
162
- bizar install --help Show this help
159
+ bizar install Install (or refresh) every component
160
+ bizar install --dry-run Print what would happen, change nothing
161
+ bizar install --force Overwrite existing files
162
+ bizar install --with-mods a,b,c Opt-in: install specific mods as part of the run
163
+ bizar install --help Show this help
163
164
 
164
165
  Description:
165
166
  v4.4.7+ — unified installer. Same code path as 'bizar update'; the
@@ -194,6 +195,7 @@ function showUpdateHelp() {
194
195
  bizar update --dry-run Print what would happen, change nothing
195
196
  bizar update --force Override .bizar/PRE_PUSH_NOTES.md blockers
196
197
  bizar update --yes Same as --force, but named for one-line scripts
198
+ bizar update --with-mods a,b,c Opt-in: install specific mods as part of the run
197
199
  bizar update --help Show this help
198
200
 
199
201
  Components updated:
@@ -580,6 +582,22 @@ function parseFlag(name) {
580
582
  return args[idx + 1] || null;
581
583
  }
582
584
 
585
+ /**
586
+ * v4.4.11 — Parse `--with-mods <csv>` from the given subargs slice.
587
+ * Returns `null` if the flag isn't present (the provisioner's
588
+ * "don't touch mods" default), or a string[] of mod ids if it is.
589
+ */
590
+ function parseWithModsFlag(subargs) {
591
+ const idx = subargs.indexOf('--with-mods');
592
+ if (idx === -1) return null;
593
+ const raw = subargs[idx + 1];
594
+ if (!raw || raw.startsWith('--')) return [];
595
+ return raw
596
+ .split(',')
597
+ .map((s) => s.trim())
598
+ .filter(Boolean);
599
+ }
600
+
583
601
  async function readAutoLaunchWeb() {
584
602
  try {
585
603
  const fs = await import('node:fs');
@@ -677,7 +695,19 @@ async function main() {
677
695
  else await runTestGate();
678
696
  } else if (args[0] === 'update') {
679
697
  if (isHelpRequest) showUpdateHelp();
680
- else await runUpdate(args.slice(1));
698
+ else {
699
+ // v4.4.11 — Same --with-mods opt-in for update.
700
+ const withMods = parseWithModsFlag(args.slice(1));
701
+ // runUpdate expects (subargs: string[], opts?: object). Splice
702
+ // --with-mods <csv> out of subargs since the provisioner now
703
+ // takes it via opts, not as a positional arg.
704
+ const subargs = args.slice(1).filter((a, i, arr) => {
705
+ if (a === '--with-mods') return false;
706
+ if (arr[i - 1] === '--with-mods') return false;
707
+ return true;
708
+ });
709
+ await runUpdate(subargs, { withMods });
710
+ }
681
711
  } else if (args[0] === 'dev-link') {
682
712
  if (isHelpRequest) showDevLinkHelp();
683
713
  else {
@@ -741,7 +771,10 @@ async function main() {
741
771
  } else if (args[0] === 'install') {
742
772
  if (isHelpRequest) showInstallHelp();
743
773
  else {
744
- await runInstaller();
774
+ // v4.4.11 — Parse --with-mods <csv> to opt into mod installs
775
+ // during the run. Default: mods are NEVER touched.
776
+ const withMods = parseWithModsFlag(args.slice(1));
777
+ await runInstaller({ withMods });
745
778
  // v4.4.3 — After install, repair any stale bin symlinks so the
746
779
  // user picks up the new code (the installer itself may have
747
780
  // been running from a stale install path).
package/cli/provision.mjs CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  import chalk from 'chalk';
40
40
  import { execSync, spawn, spawnSync } from 'node:child_process';
41
- import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
41
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
42
42
  import { homedir } from 'node:os';
43
43
  import { join, dirname } from 'node:path';
44
44
  import { fileURLToPath } from 'node:url';
@@ -272,6 +272,13 @@ export function detectState({ cwd = process.cwd() } = {}) {
272
272
  // ── Git checkout? ─────────────────────────────────────────────────
273
273
  const gitRepo = existsSync(join(REPO_ROOT, '.git'));
274
274
 
275
+ // ── Installed mods ─────────────────────────────────────────────────
276
+ // v4.4.11 — `bizar install` and `bizar update` never install or
277
+ // upgrade mods. The list below is informational only. Use
278
+ // `bizar mod install <id>` to add a mod explicitly.
279
+ const MODS_DIR = join(BIZAR_HOME, 'mods');
280
+ const installedMods = listInstalledMods(MODS_DIR);
281
+
275
282
  return {
276
283
  pkgRoot,
277
284
  pkgVersion,
@@ -302,9 +309,52 @@ export function detectState({ cwd = process.cwd() } = {}) {
302
309
  opencodeCli,
303
310
  headsUpState,
304
311
  gitRepo,
312
+ installedMods,
305
313
  };
306
314
  }
307
315
 
316
+ /**
317
+ * v4.4.11 — Lightweight read-only scan of `~/.config/bizar/mods/`.
318
+ * Returns one entry per mod folder with the id + enabled flag parsed
319
+ * from mod.json. Never throws; mods with a missing or invalid mod.json
320
+ * are reported as `{id, error}` so the provisioner can surface them.
321
+ */
322
+ function listInstalledMods(modsDir) {
323
+ const out = [];
324
+ if (!existsSync(modsDir)) return out;
325
+ let entries;
326
+ try {
327
+ entries = readdirSync(modsDir, { withFileTypes: true });
328
+ } catch {
329
+ return out;
330
+ }
331
+ for (const entry of entries) {
332
+ if (!entry.isDirectory()) continue;
333
+ const dir = join(modsDir, entry.name);
334
+ const manifestPath = join(dir, 'mod.json');
335
+ if (!existsSync(manifestPath)) {
336
+ out.push({ id: entry.name, error: 'missing mod.json' });
337
+ continue;
338
+ }
339
+ let manifest;
340
+ try {
341
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
342
+ } catch {
343
+ out.push({ id: entry.name, error: 'invalid mod.json' });
344
+ continue;
345
+ }
346
+ out.push({
347
+ id: manifest.id || entry.name,
348
+ name: manifest.name || entry.name,
349
+ version: manifest.version || '?',
350
+ enabled: manifest.enabled !== false,
351
+ installedAt: manifest.installedAt || null,
352
+ path: dir,
353
+ });
354
+ }
355
+ return out;
356
+ }
357
+
308
358
  /**
309
359
  * Lightweight CommonJS-ish require for ESM contexts. Used to detect the
310
360
  * service-controller + heads-up modules without paying the static-import
@@ -681,6 +731,125 @@ export async function runDoctor({ silent = false } = {}) {
681
731
  }
682
732
  }
683
733
 
734
+ /**
735
+ * v4.4.11 — The mods step. By default, NEVER install or upgrade mods
736
+ * during `bizar install` or `bizar update`. The step just reports the
737
+ * current mod list so the user can see what's installed.
738
+ *
739
+ * To install a mod as part of the run, the user must opt in by:
740
+ * - passing `--with-mods <id1,id2>` to `bizar install` / `bizar update`
741
+ * - or setting the env var `BIZAR_MODS_AUTO_INSTALL=allow`
742
+ *
743
+ * When opted in, we shell to the running dashboard's `/api/mods`
744
+ * endpoint (which already validates the mod) — we don't duplicate the
745
+ * install logic here.
746
+ */
747
+ export async function runModsStep({ mode, dryRun, force, withMods, state }) {
748
+ // List the current mods (from the state we already detected).
749
+ const installed = state?.installedMods ?? [];
750
+ if (installed.length === 0) {
751
+ console.log(chalk.dim(' Mods: 0 installed.'));
752
+ return { ok: true, message: 'no mods installed', touched: false };
753
+ }
754
+
755
+ const enabled = installed.filter((m) => m.enabled).length;
756
+ const disabled = installed.length - enabled;
757
+ const summary = `${installed.length} installed (${enabled} enabled${disabled ? `, ${disabled} disabled` : ''})`;
758
+
759
+ // Resolve which mods the user asked us to install. The CLI parses
760
+ // `--with-mods a,b,c` into a string array; the provisioner accepts
761
+ // the same. We also accept a single env var as a comma-separated list.
762
+ const envList = (process.env.BIZAR_MODS_AUTO_INSTALL || '')
763
+ .split(',')
764
+ .map((s) => s.trim())
765
+ .filter(Boolean);
766
+ const wantedMods = Array.isArray(withMods) && withMods.length > 0
767
+ ? withMods
768
+ : (process.env.BIZAR_MODS_AUTO_INSTALL === 'allow' ? [] : envList);
769
+
770
+ // Default behavior: no install. Just print the list.
771
+ if (!wantedMods || wantedMods.length === 0) {
772
+ console.log(chalk.dim(` Mods: ${summary}. Not modified (use \`bizar mod install <id>\` to add one).`));
773
+ // Surface mods with errors so the user knows they need attention.
774
+ const broken = installed.filter((m) => m.error);
775
+ for (const m of broken) {
776
+ console.log(chalk.yellow(` ⚠ ${m.id}: ${m.error}`));
777
+ }
778
+ return { ok: true, message: `${summary}, not modified`, touched: false };
779
+ }
780
+
781
+ // Opt-in install path. Talk to the dashboard over HTTP — the
782
+ // dashboard's `POST /api/mods` endpoint already does the actual
783
+ // install + validation. If the dashboard isn't reachable, the
784
+ // install fails loudly.
785
+ console.log(chalk.cyan(` Installing ${wantedMods.length} mod(s) via dashboard API: ${wantedMods.join(', ')}`));
786
+ const errors = [];
787
+ for (const id of wantedMods) {
788
+ try {
789
+ const result = await installModViaDashboard(id, { dryRun });
790
+ if (result.ok) {
791
+ console.log(chalk.green(` ✓ ${id}: ${result.message}`));
792
+ } else {
793
+ console.log(chalk.red(` ✗ ${id}: ${result.message}`));
794
+ errors.push(`${id}: ${result.message}`);
795
+ }
796
+ } catch (err) {
797
+ const msg = err instanceof Error ? err.message : String(err);
798
+ console.log(chalk.red(` ✗ ${id}: ${msg}`));
799
+ errors.push(`${id}: ${msg}`);
800
+ }
801
+ }
802
+ if (errors.length > 0) {
803
+ return {
804
+ ok: false,
805
+ message: `mod install failed for ${errors.length} mod(s): ${errors.join('; ')}`,
806
+ touched: true,
807
+ };
808
+ }
809
+ return { ok: true, message: `${wantedMods.length} mod(s) installed`, touched: true };
810
+ }
811
+
812
+ /**
813
+ * v4.4.11 — POST to the dashboard's /api/mods endpoint to install a mod.
814
+ * We re-use the dashboard's own validation pipeline (mod-loader.mjs
815
+ * runs the same manifest + route.mjs + permissions checks) so we
816
+ * don't have to duplicate them here.
817
+ */
818
+ async function installModViaDashboard(id, { dryRun }) {
819
+ if (dryRun) {
820
+ return { ok: true, message: '[dry-run] would install via dashboard' };
821
+ }
822
+ // Find the dashboard's port from BIZAR_HOME/dashboard.port.
823
+ const portFile = join(BIZAR_HOME, 'dashboard.port');
824
+ let port = 4321;
825
+ try {
826
+ if (existsSync(portFile)) {
827
+ const parsed = parseInt(readTextSafe(portFile, '4321').trim(), 10);
828
+ if (Number.isFinite(parsed) && parsed > 0) port = parsed;
829
+ }
830
+ } catch { /* ignore */ }
831
+ // We need an auth token. The dashboard's install endpoint is under
832
+ // /api/* which is auth-gated. Fetch the auth status + token via
833
+ // /api/auth/status (skipped from auth via the skipPaths list in
834
+ // server.mjs). If the dashboard has auth enabled, the user needs to
835
+ // supply a token via BIZAR_DASHBOARD_TOKEN.
836
+ const headers = { 'content-type': 'application/json' };
837
+ const token = process.env.BIZAR_DASHBOARD_TOKEN;
838
+ if (token) headers['authorization'] = `Basic ${Buffer.from(`opencode:${token}`).toString('base64')}`;
839
+ const url = `http://127.0.0.1:${port}/api/mods`;
840
+ const res = await fetch(url, {
841
+ method: 'POST',
842
+ headers,
843
+ body: JSON.stringify({ id }),
844
+ });
845
+ if (!res.ok) {
846
+ const text = await res.text().catch(() => '');
847
+ return { ok: false, message: `dashboard returned ${res.status}: ${text.slice(0, 200) || '(no body)'}` };
848
+ }
849
+ const data = await res.json().catch(() => ({}));
850
+ return { ok: true, message: data?.name ? `installed ${data.name}@${data.version}` : 'installed' };
851
+ }
852
+
684
853
  // ─── Top-level orchestration ──────────────────────────────────────────────────
685
854
 
686
855
  /**
@@ -785,6 +954,7 @@ export async function runProvision(opts = {}) {
785
954
  skipSystemDeps = false,
786
955
  skipHeadsUps = false,
787
956
  yes = false,
957
+ withMods = null, // string[] — opt-in mod install. Default null = don't touch mods.
788
958
  } = opts;
789
959
 
790
960
  const banner = mode === 'update'
@@ -897,7 +1067,17 @@ export async function runProvision(opts = {}) {
897
1067
  stepResults.push({ label: 'dashboard-restart', ...r });
898
1068
  }
899
1069
 
900
- // ── 11. Doctor health check ────────────────────────────────────────
1070
+ // ── 11. Mods (opt-in only) ─────────────────────────────────────────
1071
+ // v4.4.11 — `bizar install` and `bizar update` never install or
1072
+ // upgrade mods by default. The provisioner reports the current mod
1073
+ // list so the user can see what's installed, then exits the mod step
1074
+ // without touching anything. To install a mod, pass
1075
+ // `--with-mods <id1,id2>` or set `BIZAR_MODS_AUTO_INSTALL=allow`.
1076
+ console.log('');
1077
+ const modsStep = await runModsStep({ mode, dryRun, force, withMods, state });
1078
+ stepResults.push({ label: 'mods', ...modsStep });
1079
+
1080
+ // ── 12. Doctor health check ───────────────────────────────────────
901
1081
  console.log('');
902
1082
  const doctor = await runDoctor({ silent: true });
903
1083
  if (doctor.failed > 0) {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: glyph
3
- description: Create and consume Bizar glyphs — visual artifacts at `artifacts/<slug>/`. Use for plans, recaps, design proposals, postmortems, handoffs. Quick reference for the block vocabulary and the 5 things that actually break a glyph.
4
- version: 3
3
+ description: Create visual glyphs at `artifacts/<slug>/` for plans, recaps, design proposals, postmortems, handoffs. Glyphs should be compact and visual one screen, dense info, no walls of text. This skill enforces that.
4
+ version: 4
5
5
  ---
6
6
 
7
7
  # Glyphs
@@ -15,7 +15,7 @@ artifacts/<slug>/
15
15
  └── comments.json ← free-placed pins (mutable)
16
16
  ```
17
17
 
18
- Two locations are scanned: `<projectRoot>/.bizar/artifacts/` (preferred) and `~/.config/opencode/artifacts/` (global).
18
+ Two locations: `<projectRoot>/.bizar/artifacts/` (preferred) and `~/.config/opencode/artifacts/` (global).
19
19
 
20
20
  ## Frontmatter
21
21
 
@@ -28,104 +28,121 @@ kind: plan | postmortem | recap | design
28
28
  ---
29
29
  ```
30
30
 
31
- ## The blocks at a glance
31
+ ## The 6 rules read this first, every time
32
+
33
+ These are non-negotiable. The existing 357-line v3-to-v4-consolidation glyph is a cautionary tale — don't write that one.
34
+
35
+ ### Rule 1 — One screen
36
+
37
+ A glyph MUST fit on one screen of the dashboard (≈ 1000px tall at desktop width). Long glyphs are skimmed and abandoned. If you can't fit the work in 5-10 blocks, you're covering too much. Break the work into multiple glyphs, one per phase.
38
+
39
+ ### Rule 2 — One idea per block
40
+
41
+ If a block covers two ideas, split it. Each block is a single visual unit. The dashboard groups blocks into sections by id prefix — use that.
42
+
43
+ ### Rule 3 — RichText is glue, not body
44
+
45
+ RichText is for 1-3 sentence transitions between visual blocks. NEVER use RichText for the actual content. If you find yourself writing 4+ sentences in a RichText, switch to:
46
+
47
+ - **A table** if you're listing things with attributes
48
+ - **A decision** if you're explaining why one option won
49
+ - **A file tree** if you're listing changes
50
+ - **A stat** if you're highlighting a number
51
+ - **A workflow** if you're describing a sequence
52
+
53
+ ### Rule 4 — Lead with visuals
54
+
55
+ The first block after the title/headline should be a visual: a Stat, a Callout, or a Table. Never open with a long RichText. The user decides in 5 seconds whether to keep scrolling based on the visual.
56
+
57
+ ### Rule 5 — One headline callout
58
+
59
+ Use `<Callout tone="success|danger">` once, near the top, as the TL;DR. Everything else supports it.
60
+
61
+ ### Rule 6 — Truncate hints, not bodies
62
+
63
+ Stat `hint` props should be 1 short clause. Long hints mean the value needs to be a different block.
64
+
65
+ ## The block vocabulary — minimal reference
32
66
 
33
67
  | Block | What it shows | Required `data` |
34
68
  | --- | --- | --- |
35
- | `<RichText>` | Prose, lists, code blocks | none — body is markdown |
36
- | `<Callout tone="info\|warn\|success\|danger">` | Boxed callout | none (tone defaults to info) |
37
- | `<Stat label value trend hint />` | Big number with trend | `label`, `value` |
38
- | `<Checklist items={[...]} />` | Checkbox list | `items: [{id,label,checked}]` |
69
+ | `<RichText>` | 1-3 sentences of glue | none |
70
+ | `<Callout tone>` | Boxed TL;DR | none (tone defaults to info) |
71
+ | `<Stat label value trend hint />` | Big number + label | `label`, `value` |
72
+ | `<Checklist items />` | Checkbox list | `items: [{id,label,checked}]` |
39
73
  | `<Table columns rows />` | Tabular data | `columns: []`, `rows: [[]]` |
40
- | `<CodeTabs tabs />` | Tabbed code blocks | `tabs: [{id,label,language,code}]` |
74
+ | `<CodeTabs tabs />` | Tabbed code | `tabs: [{id,label,language,code}]` |
41
75
  | `<Decision title question options />` | Multi-option choice | `options: [{id,label,detail,recommended?}]` |
42
76
  | `<OpenQuestions questions />` | Unanswered questions | `questions: [{id,label,kind,options?}]` |
43
- | `<FileTree title entries />` | File-by-file change list | `entries: [{path,change,note?}]` |
44
- | `<Diff before after filename language mode />` | Before/after diff | `before`, `after` |
77
+ | `<FileTree title entries />` | File-by-file changes | `entries: [{path,change,note?}]` |
78
+ | `<Diff before after filename language mode />` | Before/after | `before`, `after` |
45
79
  | `<Workflow steps connections />` | Node graph | `steps: [{id,label,type}]` |
46
80
  | `<Mockup title x y w h html />` | Inline UI mockup | `html` |
47
81
  | `<Diagram title dataHtml dataCss />` | Inline SVG | `dataHtml` |
48
82
 
49
- Every block needs a unique `id`. `RichText` and `Callout` use open/close tags with markdown children; everything else is self-closing.
83
+ Every block needs a unique `id`. RichText + Callout use open/close tags (markdown body); everything else is self-closing.
50
84
 
51
85
  ## Skeleton
52
86
 
53
87
  ```mdx
54
88
  <RichText id="overview">
55
- ## What this covers
89
+ ## What this is
56
90
 
57
- A one-paragraph summary.
91
+ One sentence the reader can't miss.
58
92
  </RichText>
59
93
 
60
- <Stat id="headline" label="X" value="7" trend="flat" />
94
+ <Stat id="headline" label="X shipped" value="7" trend="flat" hint="across v3.22 → v4.4.7" />
61
95
 
62
- <Callout id="warning" tone="warn">
63
- One important thing.
96
+ <Callout id="tldr" tone="success">
97
+ One-sentence TL;DR.
64
98
  </Callout>
65
99
 
100
+ <Table
101
+ id="releases"
102
+ columns={["v", "what", "fix"]}
103
+ rows={[
104
+ ["3.22", "unified", "—"],
105
+ ["4.4.1", "bg fix", "process exit crash"],
106
+ ]}
107
+ />
108
+
66
109
  <FileTree
67
110
  id="files"
68
111
  title="Files changed"
69
112
  entries={[
70
- { path: "src/foo.ts", change: "modified", note: "why" },
71
- { path: "src/bar.ts", change: "added" },
72
- ]}
73
- />
74
-
75
- <Workflow
76
- id="flow"
77
- steps={[
78
- { id: "s1", label: "Step 1", type: "task" },
79
- { id: "s2", label: "Step 2", type: "task" },
80
- ]}
81
- connections={[
82
- { from: "s1", to: "s2" },
113
+ { path: "cli/provision.mjs", change: "added", note: "unified provisioner" },
83
114
  ]}
84
115
  />
85
116
  ```
86
117
 
87
118
  ## The 5 things that break a glyph
88
119
 
89
- Read this BEFORE writing. Each one is a real bug we've shipped.
120
+ These shipped as bugs. Read before writing.
90
121
 
91
122
  ### 1. Backticks inside attribute strings
92
123
 
93
- The parser scans for `` ` `` to delimit template literals. A literal backtick inside a string — even inside double-quotes closes the surrounding context and breaks parsing.
124
+ The parser scans for `` ` `` and breaks. Use straight quotes or rephrase:
94
125
 
95
126
  ```
96
127
  WRONG: Run `bizar install` to bootstrap.
97
128
  RIGHT: Run 'bizar install' to bootstrap.
98
- RIGHT: Run the installer to bootstrap.
99
129
  ```
100
130
 
101
- ### 2. The parser doesn't track strings when counting braces
131
+ ### 2. Braces inside string values
102
132
 
103
- `{` and `}` inside string values still increment the brace depth walker. With brace-laden strings the walker terminates at the wrong closing brace and the value gets truncated.
133
+ The brace counter doesn't know about strings. `{` / `}` in notes still bump depth. Keep note strings brace-free.
104
134
 
105
- **Rule:** keep string values brace-free. If you must use `{...}` in a note, escape or rephrase.
135
+ ### 3. `FileTree` `change` must be exact
106
136
 
107
- ### 3. `FileTree` `change` field must be exact
137
+ `added | modified | removed | renamed`. Empty string or `null` crashes on `t.bg`. Default to `modified` with a `note` when unclear.
108
138
 
109
- `change` must be `added` | `modified` | `removed` | `renamed`. Anything else (empty string, `null`, typo) makes the renderer crash on `t.bg`.
139
+ ### 4. Duplicate block ids
110
140
 
111
- ```
112
- WRONG: change: null, change: "", change: "Modified"
113
- RIGHT: change: "modified"
114
- ```
115
-
116
- When in doubt, use `modified` with a `note` that explains why.
117
-
118
- ### 4. Two blocks with the same id
141
+ Comment pins and error reporting key off ids. Two blocks with the same id collide.
119
142
 
120
- Comment pins and error reporting are keyed by block id. Duplicates collide. Each `id` must be unique within a glyph.
143
+ ### 5. `## Heading <768px`
121
144
 
122
- ### 5. MDX heading with `<N`
123
-
124
- ```
125
- WRONG: ## Mobile <768px ← <7 looks like a malformed tag
126
- RIGHT: ## Mobile (under 768px)
127
- RIGHT: ## Mobile: under 768px
128
- ```
145
+ The parser sees `<7` as a malformed JSX tag. Use parentheses or colons.
129
146
 
130
147
  ## Validate before shipping
131
148
 
@@ -134,14 +151,13 @@ node -e "import('./bizar-dash/src/server/glyphs/mdx-compiler.mjs').then(m => { \
134
151
  const fs = require('node:fs'); \
135
152
  const out = m.compileGlyphMdxSync(fs.readFileSync('artifacts/<slug>/artifact.mdx','utf8')); \
136
153
  console.log('blocks:', out.blocks.length, 'errors:', out.errors.length); \
137
- out.errors.forEach(e => console.error(' line', e.line, ':', e.message)); \
138
154
  })"
139
155
  ```
140
156
 
141
- If errors > 0, fix the MDX before opening the glyph in the browser. When a block DOES fail in the browser, v4.4.8+ shows a red banner with block id + error message — no more blank screen.
157
+ Also check yourself: does it fit on one screen? Are there any 4-sentence RichText blocks? Are the stats/tables/file trees doing the heavy lifting? If not, rewrite.
142
158
 
143
159
  ## See also
144
160
 
145
161
  - `bizar-dash/src/server/glyphs/mdx-compiler.mjs` — parser
146
- - `bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx` — React renderer (has the error banner)
147
- - `bizar-dash/src/web/views/glyphs/components.tsx` — block component implementations
162
+ - `bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx` — React renderer (has error banner)
163
+ - `bizar-dash/src/web/views/glyphs/components.tsx` — block implementations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.4.9",
3
+ "version": "4.4.11",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {