@polderlabs/bizar 3.21.0 → 3.22.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/cli/bin.mjs CHANGED
@@ -25,6 +25,7 @@ import { runInit } from './init.mjs';
25
25
  import { runExport } from './export.mjs';
26
26
  import runArtifact from './artifact.mjs';
27
27
  import { runUpdate } from './update.mjs';
28
+ import { runHeadsUp } from './heads-up.mjs';
28
29
  import { ensureSetup, checkSetupStatus } from './bootstrap.mjs';
29
30
 
30
31
  const args = process.argv.slice(2);
@@ -90,6 +91,7 @@ function showHelp() {
90
91
  dev-link [src] Symlink the local plugin source into opencode's plugin dir
91
92
  dev-unlink Remove the dev symlink and restore the deployed copy
92
93
  doctor Check the BizarHarness install for health issues
94
+ heads-up <subcommand> Manage pre-push / pre-release heads-ups (list/check/archive)
93
95
  mod <subcommand> Manage mods (install/upgrade/list via the dashboard API)
94
96
 
95
97
  Examples:
@@ -660,6 +662,9 @@ async function main() {
660
662
  const result = await runDoctor();
661
663
  if (result.failed > 0) process.exit(1);
662
664
  }
665
+ } else if (args[0] === 'heads-up') {
666
+ // v3.21.0 — Pre-push / pre-release heads-ups
667
+ await runHeadsUp(args[1], args.slice(2));
663
668
  } else if (args[0] === 'plan') {
664
669
  await runArtifact(args.slice(1), {});
665
670
  } else if (args[0] === 'install') {
@@ -0,0 +1,295 @@
1
+ /**
2
+ * heads-up.mjs — `bizar heads-up` subcommand.
3
+ *
4
+ * Reads and manages .bizar/PRE_PUSH_NOTES.md — a per-project file for
5
+ * tracking gotchas, risks, and things-to-verify before pushing/publishing.
6
+ *
7
+ * Subcommands:
8
+ * list Print active heads-up entries
9
+ * check Exit code 0 (no blockers) or 1 (blockers found); batches
10
+ * with warnings printed to stderr
11
+ * archive Move all active entries to the Resolved section
12
+ */
13
+
14
+ import chalk from 'chalk';
15
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Entry parsing
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Parse entries from the "Active heads-ups" section of a PRE_PUSH_NOTES.md.
24
+ * Returns an array of { title, severity, area, affected, description,
25
+ * mitigation, verified, lines } objects.
26
+ */
27
+ function parseActiveEntries(content) {
28
+ const entries = [];
29
+
30
+ // Find the Active heads-ups section. We stop at the next h2 that starts
31
+ // with an uppercase letter (section header like "## Resolved") but NOT
32
+ // at entry titles that start with a date bracket "## [YYYY-MM-DD]".
33
+ const activeMatch = content.match(/## Active heads-ups\n([\s\S]*?)(?=\n## [A-Z]|$)/);
34
+ if (!activeMatch) return entries;
35
+
36
+ const section = activeMatch[1];
37
+
38
+ // Each entry starts with "## [YYYY-MM-DD] ..."
39
+ const entryBlocks = section.split(/(?=^## \[)/m);
40
+ for (const block of entryBlocks) {
41
+ const trimmed = block.trim();
42
+ if (!trimmed || trimmed.startsWith('<!--')) continue;
43
+
44
+ const titleMatch = trimmed.match(/^##\s+(\[.+?\]\s*.+)$/m);
45
+ if (!titleMatch) continue;
46
+
47
+ const severityMatch = trimmed.match(/^Severity:\s*(\S+)/m);
48
+ const areaMatch = trimmed.match(/^Area:\s*(.+)$/m);
49
+ const affectedMatch = trimmed.match(/^Affected versions:\s*(.+)$/m);
50
+ const descMatch = trimmed.match(/^Description:\s*(.+)$/m);
51
+ const mitigMatch = trimmed.match(/^Mitigation:\s*(.+)$/m);
52
+ const verifiedMatch = trimmed.match(/^Verified:\s*(.+)$/m);
53
+
54
+ entries.push({
55
+ title: titleMatch[1].trim(),
56
+ severity: severityMatch ? severityMatch[1].trim().toLowerCase() : 'info',
57
+ area: areaMatch ? areaMatch[1].trim() : null,
58
+ affected: affectedMatch ? affectedMatch[1].trim() : null,
59
+ description: descMatch ? descMatch[1].trim() : null,
60
+ mitigation: mitigMatch ? mitigMatch[1].trim() : null,
61
+ verified: verifiedMatch ? verifiedMatch[1].trim() : '☐',
62
+ lines: trimmed,
63
+ });
64
+ }
65
+
66
+ return entries;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // File helpers
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function readPrePushNotes(bizarDir) {
74
+ const file = join(bizarDir, 'PRE_PUSH_NOTES.md');
75
+ if (!existsSync(file)) return null;
76
+ return readFileSync(file, 'utf8');
77
+ }
78
+
79
+ function findBizarDir(cwd) {
80
+ // Walk up from cwd looking for .bizar/
81
+ let dir = cwd;
82
+ for (let i = 0; i < 10; i++) {
83
+ const candidate = join(dir, '.bizar');
84
+ if (existsSync(candidate)) return candidate;
85
+ const parent = join(dir, '..');
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Severity label helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function severityLabel(severity) {
97
+ switch (severity) {
98
+ case 'blocker': return chalk.bold.red('blocker');
99
+ case 'warning': return chalk.bold.yellow('warning');
100
+ case 'info': return chalk.bold.blue('info');
101
+ default: return severity;
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Subcommands
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * `bizar heads-up list` — print active entries.
111
+ */
112
+ async function listHeadsUps(bizarDir) {
113
+ const content = readPrePushNotes(bizarDir);
114
+ if (!content) {
115
+ console.log(chalk.yellow(' No .bizar/PRE_PUSH_NOTES.md found. Run `bizar init` first.'));
116
+ return;
117
+ }
118
+
119
+ const entries = parseActiveEntries(content);
120
+ if (entries.length === 0) {
121
+ console.log(chalk.green(' ✓ No active heads-ups.'));
122
+ return;
123
+ }
124
+
125
+ console.log(chalk.bold('\n ⚠ Active heads-ups:\n'));
126
+ for (const entry of entries) {
127
+ console.log(` ${chalk.bold(entry.title)} ${severityLabel(entry.severity)}`);
128
+ if (entry.area) console.log(` Area: ${entry.area}`);
129
+ if (entry.affected) console.log(` Affects: ${entry.affected}`);
130
+ if (entry.description) console.log(` ${chalk.dim(entry.description)}`);
131
+ if (entry.mitigation) console.log(` Mitigation: ${chalk.cyan(entry.mitigation)}`);
132
+ console.log(` Verified: ${entry.verified === '☑' ? chalk.green('☑') : chalk.dim('☐')}`);
133
+ console.log('');
134
+ }
135
+ }
136
+
137
+ /**
138
+ * `bizar heads-up check` — exit 0 if clear, 1 if blockers.
139
+ * Used by update/publish flows as a pre-flight gate.
140
+ */
141
+ async function checkHeadsUps(bizarDir) {
142
+ const content = readPrePushNotes(bizarDir);
143
+ if (!content) {
144
+ // No file = no pre-push notes = clear
145
+ return { ok: true, blockerCount: 0, warningCount: 0, entries: [] };
146
+ }
147
+
148
+ const entries = parseActiveEntries(content);
149
+ const blockers = entries.filter((e) => e.severity === 'blocker');
150
+ const warnings = entries.filter((e) => e.severity === 'warning');
151
+
152
+ if (blockers.length > 0) {
153
+ console.error(chalk.bold.red(`\n ✗ ${blockers.length} blocker(s) in active heads-ups:\n`));
154
+ for (const b of blockers) {
155
+ console.error(chalk.red(` • ${b.title}`));
156
+ if (b.description) console.error(chalk.dim(` ${b.description}`));
157
+ if (b.mitigation) console.error(chalk.cyan(` Fix: ${b.mitigation}`));
158
+ console.error('');
159
+ }
160
+ }
161
+ if (warnings.length > 0) {
162
+ console.error(chalk.yellow(`\n ⚠ ${warnings.length} warning(s) in active heads-ups:\n`));
163
+ for (const w of warnings) {
164
+ console.error(chalk.yellow(` • ${w.title}`));
165
+ if (w.description) console.error(chalk.dim(` ${w.description}`));
166
+ console.error('');
167
+ }
168
+ }
169
+
170
+ return {
171
+ ok: blockers.length === 0,
172
+ blockerCount: blockers.length,
173
+ warningCount: warnings.length,
174
+ entries,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * `bizar heads-up archive` — move all active entries to the Resolved section.
180
+ */
181
+ async function archiveHeadsUps(bizarDir) {
182
+ const file = join(bizarDir, 'PRE_PUSH_NOTES.md');
183
+ const content = readPrePushNotes(bizarDir);
184
+ if (!content) {
185
+ console.log(chalk.yellow(' No .bizar/PRE_PUSH_NOTES.md found. Nothing to archive.'));
186
+ return;
187
+ }
188
+
189
+ const entries = parseActiveEntries(content);
190
+ if (entries.length === 0) {
191
+ console.log(chalk.dim(' No active entries to archive.'));
192
+ return;
193
+ }
194
+
195
+ // Build resolved entries block (with indented detail lines for readability)
196
+ const today = new Date().toISOString().slice(0, 10);
197
+ const newResolvedEntries = entries
198
+ .map((e) => {
199
+ const lines = e.lines.split('\n');
200
+ const header = lines[0];
201
+ const body = lines.slice(1).map((l) => ` ${l}`).join('\n');
202
+ return `${header}\n${body}`;
203
+ })
204
+ .join('\n\n');
205
+
206
+ // Clean the Active section — strip entries, keep header + comment
207
+ const cleanActive = '## Active heads-ups\n\n<!--\n Entries archived. See "Resolved" below.\n-->\n';
208
+
209
+ let newContent;
210
+
211
+ // Check if a "## Resolved" section already exists
212
+ const resolvedMatch = content.match(/## Resolved[\s\S]*$/);
213
+ if (resolvedMatch) {
214
+ // Remove active entries from the Active section
215
+ newContent = content.replace(/## Active heads-ups[\s\S]*?(?=\n## Resolved|$)/, cleanActive);
216
+
217
+ // Prepend new resolved entries into the existing Resolved section,
218
+ // right after the header (first line "## Resolved").
219
+ const oldResolved = resolvedMatch[0];
220
+ const firstNewlineAfterHeader = oldResolved.indexOf('\n');
221
+ const prefix = oldResolved.slice(0, firstNewlineAfterHeader + 1); // "## Resolved\n"
222
+ const suffix = oldResolved.slice(firstNewlineAfterHeader + 1); // everything after
223
+
224
+ const updatedResolved = `${prefix}<!-- Resolved on ${today} -->\n\n${newResolvedEntries}\n\n${suffix.trimStart()}\n`;
225
+
226
+ newContent = newContent.replace(resolvedMatch[0], updatedResolved);
227
+ } else {
228
+ // No existing Resolved section — replace Active section and append Resolved
229
+ newContent = content.replace(/## Active heads-ups[\s\S]*?(?=\n## |$)/, cleanActive);
230
+ const resolvedSection = `## Resolved\n\n<!-- Resolved on ${today} -->\n\n${newResolvedEntries}\n`;
231
+ newContent = `${newContent.trim()}\n\n${resolvedSection}\n`;
232
+ }
233
+
234
+ writeFileSync(file, newContent);
235
+ console.log(chalk.green(` ✓ Archived ${entries.length} heads-up(s) to Resolved section.`));
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Help
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function showHeadsUpHelp() {
243
+ console.log(`
244
+ bizar heads-up — Manage pre-push / pre-release heads-ups
245
+
246
+ Usage:
247
+ bizar heads-up list Show active heads-up entries
248
+ bizar heads-up check Exit 0 if no blockers, 1 if blockers exist
249
+ bizar heads-up archive Move all active entries to Resolved
250
+
251
+ Description:
252
+ Reads .bizar/PRE_PUSH_NOTES.md in the current (or nearest parent)
253
+ project. The file is created automatically by \`bizar init\`.
254
+
255
+ Add entries as agent-discovered gotchas before pushing or publishing.
256
+ The \`check\` subcommand is invoked automatically by \`bizar update\`
257
+ to gate pushes when unresolved blockers exist.
258
+ `);
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Main entry point
263
+ // ---------------------------------------------------------------------------
264
+
265
+ export async function runHeadsUp(sub, args = []) {
266
+ const bizarDir = findBizarDir(process.cwd());
267
+
268
+ if (!sub || sub === '--help' || sub === '-h') {
269
+ showHeadsUpHelp();
270
+ return;
271
+ }
272
+
273
+ if (!bizarDir) {
274
+ console.log(chalk.yellow(' No .bizar/ directory found in this project.'));
275
+ console.log(chalk.dim(' Run `bizar init` to create one.'));
276
+ process.exit(1);
277
+ }
278
+
279
+ if (sub === 'list') {
280
+ await listHeadsUps(bizarDir);
281
+ } else if (sub === 'check') {
282
+ const result = await checkHeadsUps(bizarDir);
283
+ if (!result.ok) process.exit(1);
284
+ console.log(chalk.green(' ✓ No active blocker heads-ups.'));
285
+ } else if (sub === 'archive') {
286
+ await archiveHeadsUps(bizarDir);
287
+ } else {
288
+ console.error(chalk.red(` ✗ Unknown heads-up subcommand: ${sub}`));
289
+ showHeadsUpHelp();
290
+ process.exit(1);
291
+ }
292
+ }
293
+
294
+ // Also export utilities so update.mjs and other commands can import them
295
+ export { checkHeadsUps, parseActiveEntries, findBizarDir, readPrePushNotes };
package/cli/init.mjs CHANGED
@@ -56,6 +56,37 @@ function detectStack(cwd) {
56
56
  return stack;
57
57
  }
58
58
 
59
+ /**
60
+ * Write the .bizar/PRE_PUSH_NOTES.md template file.
61
+ * Created during `bizar init` with empty active/resolved sections.
62
+ */
63
+ export function writePrePushNotesFile(bizarDir) {
64
+ const ppnPath = join(bizarDir, 'PRE_PUSH_NOTES.md');
65
+ const template = `# Pre-push / Pre-release Heads-up
66
+
67
+ > File for tracking gotchas, risks, and things-to-verify before pushing or publishing.
68
+ > Each entry is archived (moved to \`_archive/\`) after the push that resolves it.
69
+
70
+ ## Active heads-ups
71
+
72
+ <!--
73
+ ## [YYYY-MM-DD] Short title
74
+ Severity: blocker | warning | info
75
+ Area: cli | dash | plugin | npm | docs | build
76
+ Affected versions: v3.20.0–v3.21.0
77
+ Description: What could go wrong. Be specific.
78
+ Mitigation: What to do before pushing.
79
+ Verified: ☐
80
+ -->
81
+
82
+ ## Resolved
83
+
84
+ <!-- Entries moved here after the push that resolved them. Keep last 5. -->
85
+ `;
86
+ writeFileSync(ppnPath, template);
87
+ console.log(chalk.green(` ✓ Created ${ppnPath}`));
88
+ }
89
+
59
90
  export async function runInit(cwd) {
60
91
  console.log(chalk.bold.hex('#10b981')('\n ᛗ BIZARHARNESS INIT ᛗ\n'));
61
92
 
@@ -150,6 +181,9 @@ ${stack.runner ? `- Dev: \`${stack.runner}\`` : ''}
150
181
  console.log(chalk.green(` ✓ Created ${siPath}`));
151
182
  }
152
183
 
184
+ // Generate PRE_PUSH_NOTES.md
185
+ writePrePushNotesFile(bizarDir);
186
+
153
187
  // Build per-project knowledge graph (graphify -> .bizar/graph/)
154
188
  // Soft step: never fails init. If graphify is missing or build errors,
155
189
  // the user can retry manually with `bizar graph build`.
package/cli/update.mjs CHANGED
@@ -37,12 +37,15 @@
37
37
  import chalk from 'chalk';
38
38
  import { execSync, spawn, spawnSync } from 'node:child_process';
39
39
  import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
40
+ import { cp } from 'node:fs/promises';
40
41
  import { homedir } from 'node:os';
41
42
  import { join, dirname } from 'node:path';
42
43
  import { fileURLToPath } from 'node:url';
44
+ import { checkHeadsUps, findBizarDir } from './heads-up.mjs';
43
45
 
44
46
  const __filename = fileURLToPath(import.meta.url);
45
47
  const __dirname = dirname(__filename);
48
+ const REPO_ROOT = join(__dirname, '..');
46
49
 
47
50
  const PKG_MAIN = '@polderlabs/bizar';
48
51
  const PKG_DASH = '@polderlabs/bizar-dash';
@@ -454,6 +457,111 @@ function spawnFreshDashboard({ port } = {}) {
454
457
  }
455
458
  }
456
459
 
460
+ // ---------------------------------------------------------------------------
461
+ // Repo-level update helpers
462
+ // ---------------------------------------------------------------------------
463
+
464
+ /**
465
+ * Pull the latest changes from the git origin. Exits the process if the
466
+ * pull fails (merge conflict etc.), matching the task flow — an update
467
+ * should not proceed when the working tree is dirty.
468
+ */
469
+ function runGitPull({ dryRun = false } = {}) {
470
+ if (dryRun) {
471
+ console.log(chalk.dim(' [dry-run] would run: git pull --rebase'));
472
+ return { ok: true, message: '[dry-run] git pull --rebase' };
473
+ }
474
+ try {
475
+ execSync('git pull --rebase', { stdio: 'inherit', cwd: REPO_ROOT, timeout: 60000 });
476
+ return { ok: true, message: 'git pull --rebase succeeded' };
477
+ } catch {
478
+ return { ok: false, message: 'git pull --rebase failed — resolve conflicts manually' };
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Copy bundled skills from config/skills/ to the user's .opencode/skills/
484
+ * directory. Currently installs: obsidian, glyph, read-the-damn-docs.
485
+ */
486
+ async function installSkills({ dryRun = false } = {}) {
487
+ const skills = ['obsidian', 'glyph', 'read-the-damn-docs'];
488
+ const results = [];
489
+ for (const skill of skills) {
490
+ const src = join(REPO_ROOT, 'config', 'skills', skill);
491
+ const dst = join(homedir(), '.opencode', 'skills', skill);
492
+ if (dryRun) {
493
+ if (existsSync(src)) {
494
+ console.log(chalk.dim(` [dry-run] would install skill: ${skill}`));
495
+ }
496
+ } else if (existsSync(src)) {
497
+ try {
498
+ await cp(src, dst, { recursive: true });
499
+ console.log(chalk.green(` ✓ Installed skill: ${skill}`));
500
+ results.push({ skill, ok: true });
501
+ } catch (err) {
502
+ console.log(chalk.yellow(` ⚠ Failed to install skill: ${skill} — ${err.message}`));
503
+ results.push({ skill, ok: false });
504
+ }
505
+ } else {
506
+ console.log(chalk.dim(` — Source not found: config/skills/${skill} (skipping)`));
507
+ }
508
+ }
509
+ return results;
510
+ }
511
+
512
+ /**
513
+ * Rebuild the bizar-dash frontend with Vite.
514
+ */
515
+ function rebuildDashboard({ dryRun = false } = {}) {
516
+ const dashDir = join(REPO_ROOT, 'bizar-dash');
517
+ const pkgJson = join(dashDir, 'package.json');
518
+ if (!existsSync(pkgJson)) {
519
+ return { ok: false, message: 'bizar-dash/package.json not found — is the dashboard cloned?' };
520
+ }
521
+ if (dryRun) {
522
+ console.log(chalk.dim(' [dry-run] would run: npx vite build (in bizar-dash/)'));
523
+ return { ok: true, message: '[dry-run] dashboard rebuild' };
524
+ }
525
+ console.log(chalk.dim('\n Rebuilding dashboard...'));
526
+ const r = spawnSync('npx', ['vite', 'build'], {
527
+ stdio: 'inherit', cwd: dashDir, timeout: 120000,
528
+ });
529
+ if (r.status === 0) {
530
+ return { ok: true, message: 'dashboard rebuilt' };
531
+ }
532
+ return { ok: false, message: 'dashboard rebuild failed (try: cd bizar-dash && npx vite build)' };
533
+ }
534
+
535
+ /**
536
+ * Detect and run the project's test suite. Checks for common test runners
537
+ * (npm test, pytest, cargo test, go test) and runs the first one found.
538
+ */
539
+ function runTestGate({ dryRun = false } = {}) {
540
+ if (dryRun) {
541
+ console.log(chalk.dim(' [dry-run] would detect and run local test suite'));
542
+ return { ok: true, message: '[dry-run] test gate' };
543
+ }
544
+ const cwd = process.cwd();
545
+ const suites = [
546
+ { cmd: 'npm test', check: 'package.json' },
547
+ { cmd: 'pytest', check: 'pyproject.toml' },
548
+ { cmd: 'cargo test', check: 'Cargo.toml' },
549
+ { cmd: 'go test ./...', check: 'go.mod' },
550
+ ];
551
+ for (const suite of suites) {
552
+ try {
553
+ if (existsSync(join(cwd, suite.check))) {
554
+ console.log(chalk.dim(`\n Running test suite: ${suite.cmd}...`));
555
+ execSync(suite.cmd, { stdio: 'inherit', timeout: 120000, cwd });
556
+ return { ok: true, message: `test gate passed (${suite.cmd})` };
557
+ }
558
+ } catch {
559
+ return { ok: false, message: `test gate failed (${suite.cmd})` };
560
+ }
561
+ }
562
+ return { ok: true, message: 'no test suite detected (skipped)' };
563
+ }
564
+
457
565
  // ---------------------------------------------------------------------------
458
566
  // Prompt helpers
459
567
  // ---------------------------------------------------------------------------
@@ -553,6 +661,22 @@ export async function runUpdate(subargs = []) {
553
661
  console.log(chalk.dim(' No running Bizar instances detected.'));
554
662
  }
555
663
 
664
+ // ── Git pull ──────────────────────────────────────────────────────────
665
+ if (dryRun) {
666
+ console.log(chalk.dim('\n [dry-run] would pull latest from origin (git pull --rebase)'));
667
+ } else {
668
+ console.log(chalk.dim('\n Pulling latest from origin...'));
669
+ const pull = runGitPull();
670
+ if (pull.ok) {
671
+ console.log(chalk.green(` ✓ ${pull.message}`));
672
+ } else {
673
+ console.log(chalk.red(` ✗ ${pull.message}`));
674
+ console.log(chalk.yellow(' Update aborted — resolve git conflicts and retry.'));
675
+ process.exit(1);
676
+ }
677
+ }
678
+ console.log('');
679
+
556
680
  // 2. Show installed vs. latest versions.
557
681
  const cur = {
558
682
  opencode: currentVersion('opencode-ai'),
@@ -582,6 +706,52 @@ export async function runUpdate(subargs = []) {
582
706
  }
583
707
  console.log('');
584
708
 
709
+ // ── Heads-up gate ───────────────────────────────────────────────────────
710
+ // Check .bizar/PRE_PUSH_NOTES.md for active blockers or warnings before
711
+ // allowing the update to proceed. Blocker entries require --force.
712
+ const bizarDir = findBizarDir(process.cwd());
713
+ if (bizarDir) {
714
+ const headsUp = await checkHeadsUps(bizarDir);
715
+ if (!headsUp.ok) {
716
+ if (dryRun) {
717
+ console.log(chalk.yellow(' Dry-run: found active blocker(s) — update would be blocked.'));
718
+ } else if (assumeYes || subargs.includes('--force')) {
719
+ console.log(chalk.yellow(' ⚠ Active blocker(s) present — proceeding due to --force.'));
720
+ } else {
721
+ console.error(chalk.red(' ✗ Active blocker(s) found in .bizar/PRE_PUSH_NOTES.md.'));
722
+ console.error(chalk.dim(' Archive them with `bizar heads-up archive` or'));
723
+ console.error(chalk.dim(' override with `--force`.'));
724
+ process.exit(1);
725
+ }
726
+ } else if (headsUp.warningCount > 0) {
727
+ console.log(chalk.yellow(` ⚠ ${headsUp.warningCount} warning(s) in active heads-ups.`));
728
+ // In automatic mode, print the warning count and continue.
729
+ // In interactive mode, ask for confirmation.
730
+ if (!assumeYes && !forceAll && process.stdin.isTTY) {
731
+ try {
732
+ const inquirer = await import('inquirer');
733
+ const { proceed } = await inquirer.default.prompt([
734
+ {
735
+ type: 'confirm',
736
+ name: 'proceed',
737
+ message: 'Heads-up warnings exist. Continue with update?',
738
+ default: true,
739
+ },
740
+ ]);
741
+ if (!proceed) {
742
+ console.log(chalk.yellow(' Update cancelled by user.'));
743
+ process.exit(0);
744
+ }
745
+ } catch {
746
+ // inquirer unavailable — continue anyway
747
+ }
748
+ }
749
+ } else {
750
+ console.log(chalk.green(' ✓ Heads-ups: clear'));
751
+ }
752
+ }
753
+ console.log('');
754
+
585
755
  // 3. Decide what to update. Default = everything. Any missing package
586
756
  // is automatically included so a partial install gets completed.
587
757
  let selected;
@@ -648,15 +818,39 @@ export async function runUpdate(subargs = []) {
648
818
  }
649
819
  }
650
820
 
651
- // 5. Restart the dashboard if it was running before the update.
652
- if (restartAfter && instances.dashboard && (selected.has('bizar') || selected.has('dash'))) {
821
+ // ── Install bundled skills ──────────────────────────────────────────
822
+ console.log(chalk.bold('\n → Installing bundled skills...'));
823
+ const skillResults = await installSkills({ dryRun });
824
+ const skillOk = skillResults.length === 0 || skillResults.every((r) => r.ok);
825
+
826
+ // ── Rebuild dashboard ───────────────────────────────────────────────
827
+ console.log(chalk.bold('\n → Rebuilding dashboard...'));
828
+ const dashRebuild = rebuildDashboard({ dryRun });
829
+ if (dashRebuild.ok) {
830
+ console.log(chalk.green(` ✓ ${dashRebuild.message}`));
831
+ } else {
832
+ console.log(chalk.yellow(` ⚠ ${dashRebuild.message}`));
833
+ }
834
+
835
+ // ── Test gate ───────────────────────────────────────────────────────
836
+ console.log(chalk.bold('\n → Running test gate...'));
837
+ const testResult = runTestGate({ dryRun });
838
+ if (testResult.ok) {
839
+ console.log(chalk.green(` ✓ ${testResult.message}`));
840
+ } else {
841
+ console.log(chalk.red(` ✗ ${testResult.message}`));
842
+ }
843
+
844
+ // ── Restart dashboard ─────────────────────────────────────────────
845
+ // Restart if it was running before, or if the dashboard was just rebuilt.
846
+ if (restartAfter && (instances.dashboard || dashRebuild.ok)) {
653
847
  if (dryRun) {
654
848
  console.log('');
655
849
  console.log(chalk.dim(' [dry-run] would restart dashboard with the new code'));
656
850
  } else {
657
851
  console.log('');
658
852
  console.log(chalk.cyan(' Restarting dashboard with the new code...'));
659
- const res = spawnFreshDashboard({ port: instances.dashboard.port || undefined });
853
+ const res = spawnFreshDashboard({ port: instances.dashboard?.port || undefined });
660
854
  if (res.ok) {
661
855
  console.log(chalk.green(` ✓ ${res.message}`));
662
856
  } else {
@@ -673,11 +867,29 @@ export async function runUpdate(subargs = []) {
673
867
  const marker = r.ok ? chalk.green('✓') : chalk.red('✗');
674
868
  console.log(` ${marker} ${name.padEnd(10)} ${r.message}`);
675
869
  }
870
+ // Skills
871
+ for (const sr of skillResults) {
872
+ const marker = sr.ok ? chalk.green('✓') : chalk.red('✗');
873
+ console.log(` ${marker} ${'skill/skills'.padEnd(10)} ${sr.ok ? 'installed' : `failed: ${sr.skill}`}`);
874
+ }
875
+ if (!dashRebuild.ok) {
876
+ console.log(` ${chalk.yellow('⚠')} ${'dash'.padEnd(10)} ${dashRebuild.message}`);
877
+ }
878
+ const testMarker = testResult.ok ? chalk.green('✓') : chalk.red('✗');
879
+ console.log(` ${testMarker} ${'test-gate'.padEnd(10)} ${testResult.message}`);
676
880
 
677
- const anyFail = results.some(([, r]) => !r.ok);
881
+ const allResults = [
882
+ ...results.map(([, r]) => r),
883
+ ...skillResults,
884
+ dashRebuild,
885
+ testResult,
886
+ ];
887
+ const anyFail = allResults.some((r) => !r.ok);
678
888
  if (anyFail) {
679
- console.log(chalk.yellow('\n Some updates failed. See messages above.'));
680
- process.exit(1);
889
+ console.log(chalk.yellow('\n Some steps had issues. See messages above.'));
890
+ if (allResults.filter((r) => !r.ok).some((r) => r.message && r.message.includes('failed'))) {
891
+ process.exit(1);
892
+ }
681
893
  }
682
894
  if (dryRun) {
683
895
  console.log(
@@ -616,3 +616,38 @@ Rules:
616
616
  - Keep it concise — 20-40 lines max.
617
617
  - Don't duplicate what's in `AGENTS_SELF_IMPROVEMENT.md`.
618
618
  - First creation is done by `@mimir` at Odin's request (explores codebase and writes it).
619
+
620
+ ---
621
+
622
+ ## 12. Obsidian Vault — Project Knowledge Is Your First Stop
623
+
624
+ **Project knowledge lives in `.obsidian/`. Read it before you start, write to it when you learn.**
625
+
626
+ Every Bizar project has a vault at `.obsidian/` with index files (`index/`), agent-specific memory (`agents/`), bug postmortems (`bugs/`), design decisions, and ongoing work notes. The user has been working on this project — their notes contain the real context, the gotchas, the failed approaches, the preferred patterns. **Read the relevant vault entries before making any non-trivial decision.**
627
+
628
+ **When to read:**
629
+ - At the start of every session (skim the index)
630
+ - Before any non-trivial implementation decision
631
+ - When you're about to suggest something the user has already tried
632
+ - When the codebase feels like it's working around something you don't understand
633
+
634
+ **When to write:**
635
+ - After completing a meaningful piece of work
636
+ - On discovering a bug or postmortem
637
+ - When you find a pattern that should be reused
638
+ - When the user corrects you
639
+ - When you make a design decision that should be remembered
640
+
641
+ **What to write:**
642
+ - Date-stamped entry under the appropriate category (`index/`, `bugs/`, `agents/<name>/`, etc.)
643
+ - The context: what was happening
644
+ - The lesson: what you learned
645
+ - The pattern: what to do next time
646
+ - Link to related entries with `[[wikilinks]]`
647
+
648
+ **What NOT to write:**
649
+ - Secrets, API keys, tokens (use auth.json)
650
+ - Temporary scratch
651
+ - Anything already obvious from reading the code
652
+
653
+ **The O in Odin stands for "oblige the system": Odin ALWAYS updates the vault after significant work, regardless of who did the work. The other agents read; Odin writes.**