@polderlabs/bizar 5.5.4 → 5.5.6

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.
@@ -670,7 +670,11 @@ export async function ensureRunning(config, opts) {
670
670
  * '0' / 'false' / 'no', skip.
671
671
  * 3. Check if the server is already running — if so, return
672
672
  * ok=true, started=false, reason='already-running'.
673
- * 4. Otherwise call `startServer(config)` and return its result.
673
+ * 4. Probe LLM availability (detectAvailableLLM) if unreachable
674
+ * AND no API key is configured, log a clear warning but still
675
+ * attempt to start the server (the user may have installed
676
+ * ollama mid-session or configured a key after boot).
677
+ * 5. Call `startServer(config)` and return its result.
674
678
  *
675
679
  * Errors are caught and logged as warnings — startup must not fail
676
680
  * the dashboard just because LightRAG couldn't be started (the user
@@ -727,7 +731,22 @@ export async function lightragStartupHook(projectRoot, opts = {}) {
727
731
  warn('startup hook: isRunning probe failed:', err?.message || err);
728
732
  }
729
733
 
730
- // 6. Try to start.
734
+ // 6. Probe LLM availability — log a warning if unreachable + no API key,
735
+ // but still attempt to start (user may have installed ollama mid-session).
736
+ const llm = await detectAvailableLLM(config);
737
+ if (!llm) {
738
+ const binding = config.llmBinding || 'ollama';
739
+ warn(
740
+ `startup hook: ${binding} is not reachable and no API key is configured. ` +
741
+ `LightRAG may start in a degraded state. ` +
742
+ `Set an API key env var (OPENAI_API_KEY, ANTHROPIC_API_KEY, or MINIMAX_API_KEY) ` +
743
+ `or ensure ollama is running.`,
744
+ );
745
+ } else {
746
+ log(`startup hook: LLM detected: ${llm.provider} (${llm.detail})`);
747
+ }
748
+
749
+ // 7. Try to start — still attempt even if LLM probe failed (see step 6 note).
731
750
  try {
732
751
  const r = await startServer(config, opts);
733
752
  if (r.ok) {
@@ -843,6 +843,77 @@ export function vaultStats(projectRoot) {
843
843
  };
844
844
  }
845
845
 
846
+ // ── v5.5.2 Legacy config migration ─────────────────────────────────────────
847
+ //
848
+ // When the user upgrades from pre-v5.5.2, their `.bizar/memory.json` may
849
+ // still point `git.repoPath` at the legacy path
850
+ // (`~/.local/share/bizar/memory/bizar-memory`). The new default vault root
851
+ // is `~/.bizar_memory/bizar-memory`. This helper detects the stale path and
852
+ // auto-corrects the config so all git checks work immediately after upgrade.
853
+ //
854
+ // Behaviour:
855
+ // - Only migrates if the legacy path doesn't exist but the new one does.
856
+ // - Only migrates if `git.repoPath` is explicitly set in the config.
857
+ // - Idempotent — once migrated, the new path exists and the check is a
858
+ // no-op on subsequent boots.
859
+ // - Returns { migrated: true } only on actual change; { migrated: false }
860
+ // for all other cases (already correct, not applicable, or failure).
861
+ //
862
+ // Called once at dashboard startup from server.mjs.
863
+
864
+ /**
865
+ * Detect and fix a stale `git.repoPath` pointing at the legacy vault location.
866
+ *
867
+ * @param {string} projectRoot — project root (where `.bizar/memory.json` lives)
868
+ * @returns {{ migrated: boolean, from?: string, to?: string, error?: string }}
869
+ */
870
+ export function migrateLegacyGitRepoPath(projectRoot) {
871
+ const configPath = join(projectRoot, '.bizar', 'memory.json');
872
+ let raw;
873
+ try {
874
+ raw = safeReadJSON(configPath, null);
875
+ } catch {
876
+ return { migrated: false };
877
+ }
878
+ if (!raw) return { migrated: false };
879
+
880
+ const gitBlock = raw.git || raw.memoryRepo || {};
881
+ const currentRepoPath = gitBlock.repoPath;
882
+ if (!currentRepoPath || typeof currentRepoPath !== 'string') return { migrated: false };
883
+
884
+ const expanded = currentRepoPath.startsWith('~')
885
+ ? join(HOME, currentRepoPath.slice(1))
886
+ : currentRepoPath;
887
+
888
+ // Only migrate when the configured path doesn't exist but the new default does.
889
+ if (existsSync(expanded)) return { migrated: false };
890
+
891
+ const newDefault = join(DEFAULT_MEMORY_VAULT, 'bizar-memory');
892
+ if (!existsSync(newDefault)) return { migrated: false };
893
+
894
+ // Verify the new path is actually a git repo before migrating.
895
+ if (!existsSync(join(newDefault, '.git'))) return { migrated: false };
896
+
897
+ // Apply the fix: update gitBlock.repoPath (or memoryRepo.repoPath) to newDefault.
898
+ try {
899
+ const updated = JSON.parse(JSON.stringify(raw));
900
+ if (!updated.git) updated.git = {};
901
+ if (!updated.memoryRepo) updated.memoryRepo = {};
902
+ updated.git.repoPath = newDefault;
903
+ updated.memoryRepo.path = newDefault;
904
+ atomicWriteJson(configPath, updated);
905
+ console.warn(
906
+ `[memory-store] Auto-migrated git.repoPath from legacy path\n` +
907
+ `[memory-store] FROM: ${expanded}\n` +
908
+ `[memory-store] TO: ${newDefault}\n` +
909
+ `[memory-store] Please restart the dashboard to pick up the new path.`,
910
+ );
911
+ return { migrated: true, from: expanded, to: newDefault };
912
+ } catch (err) {
913
+ return { migrated: false, error: err.message };
914
+ }
915
+ }
916
+
846
917
  // ── LightRAG integration (v4.1.0) ──────────────────────────────────────────
847
918
  //
848
919
  // Re-export the LightRAG orchestrator from the memory-store module so the
@@ -369,7 +369,21 @@ export async function fetchRemains({ force = false } = {}) {
369
369
  let body = null;
370
370
  try { body = text ? JSON.parse(text) : null; } catch { /* keep as null */ }
371
371
  if (!resp.ok) {
372
- const errMsg = body?.base_resp?.status_msg || resp.statusText || 'request failed';
372
+ // v5.5.2 Provide descriptive messages for common failure modes
373
+ // rather than surfacing raw HTTP status text.
374
+ let errMsg;
375
+ if (resp.status === 404) {
376
+ errMsg =
377
+ 'MiniMax token plan API returned 404. The endpoint may have changed — ' +
378
+ 'this may require a BizarHarness update. group_id=' +
379
+ encodeURIComponent(resolved.groupId);
380
+ } else if (resp.status === 401 || resp.status === 403) {
381
+ errMsg = 'MiniMax API key is invalid or expired. Check your key in the dashboard onboarding.';
382
+ } else if (resp.status === 429) {
383
+ errMsg = 'MiniMax API rate limit hit. Try again in a few minutes.';
384
+ } else {
385
+ errMsg = body?.base_resp?.status_msg || resp.statusText || 'request failed';
386
+ }
373
387
  await recordUsageDynamic({
374
388
  providerId: 'minimax',
375
389
  modelId: 'unknown',
@@ -298,23 +298,41 @@ export function createMemoryRouter({ projectRoot }) {
298
298
  }));
299
299
 
300
300
  // POST /memory/git/pull
301
+ // v5.5.2 — Returns 200 with { ok: false } for operational failures
302
+ // (vault missing, git missing, pull errors). Only returns 503 for truly
303
+ // catastrophic failures. This allows the UI to show a clear message rather
304
+ // than treating a failed pull as a server-internal error.
301
305
  router.post('/memory/git/pull', wrap(async (_req, res) => {
302
306
  const { resolveVault } = memoryStore;
303
307
  const { pull, isGitInstalled } = memoryGit;
304
308
  const { projectVaultRoot, mode } = resolveVault(projectRoot);
305
309
 
306
310
  if (mode === 'local-only') {
307
- res.status(400).json({ error: 'local_only_mode' });
311
+ res.json({ ok: false, error: 'local_only_mode', message: 'pull is not available in local-only mode' });
308
312
  return;
309
313
  }
314
+
315
+ if (!existsSync(projectVaultRoot)) {
316
+ res.json({
317
+ ok: false,
318
+ error: 'vault_not_found',
319
+ message: `Vault directory not found at ${projectVaultRoot}. Run \`bizar memory init\` to create it.`,
320
+ });
321
+ return;
322
+ }
323
+
310
324
  if (!isGitInstalled()) {
311
- res.status(503).json({ error: 'git_not_installed' });
325
+ res.json({
326
+ ok: false,
327
+ error: 'git_not_installed',
328
+ message: 'git is not installed or not found on PATH. Install git to enable sync.',
329
+ });
312
330
  return;
313
331
  }
314
332
 
315
333
  const result = pull(projectVaultRoot);
316
334
  if (!result.ok) {
317
- res.status(500).json({ error: 'pull_failed', message: result.error });
335
+ res.json({ ok: false, error: 'pull_failed', message: result.error });
318
336
  return;
319
337
  }
320
338
  res.json({ ok: true, output: result.output });
@@ -1001,20 +1019,33 @@ export function createMemoryRouter({ projectRoot }) {
1001
1019
  }
1002
1020
 
1003
1021
  const { vaultRoot, projectVaultRoot, mode } = resolveVault(projectRoot);
1004
- const vaultExists = existsSync(projectVaultRoot);
1022
+ // v5.5.2 Check the GENERAL vault root (vaultRoot), not the project
1023
+ // subdirectory (projectVaultRoot). The general vault exists at
1024
+ // ~/.bizar_memory if the user has ever run `bizar memory init` or if
1025
+ // auto-migration ran. The project subdirectory
1026
+ // (~/.bizar_memory/projects/<projectId>/) is only created lazily on
1027
+ // first write.
1028
+ const generalVaultExists = existsSync(vaultRoot);
1029
+ const projectVaultExists = existsSync(projectVaultRoot);
1005
1030
  checks.push({
1006
1031
  name: 'vault_exists',
1007
- pass: vaultExists,
1008
- detail: vaultExists ? vaultRoot : 'vault directory missing',
1032
+ pass: generalVaultExists,
1033
+ detail: generalVaultExists
1034
+ ? projectVaultExists
1035
+ ? vaultRoot
1036
+ : `${vaultRoot} (project subdirectory not yet created — click Initialize)`
1037
+ : `vault directory missing — run \`bizar memory init\` to create it`,
1009
1038
  });
1010
- if (vaultExists) score += 20;
1039
+ if (generalVaultExists) score += 20;
1011
1040
 
1012
1041
  // Writable?
1042
+ // v5.5.2 — Write to vaultRoot (the general vault), not projectVaultRoot,
1043
+ // since the project subdirectory may not exist yet.
1013
1044
  let writable = false;
1014
- if (vaultExists) {
1045
+ if (generalVaultExists) {
1015
1046
  try {
1016
- const probe = join(projectVaultRoot, '.health-probe.tmp');
1017
- writeFileSync(probe, 'ok');
1047
+ const probe = join(vaultRoot, '.health-probe.tmp');
1048
+ writeFileSync(probe, 'ok', 'utf8');
1018
1049
  try {
1019
1050
  const { unlinkSync } = await import('node:fs');
1020
1051
  unlinkSync(probe);
@@ -1024,13 +1055,20 @@ export function createMemoryRouter({ projectRoot }) {
1024
1055
  writable = false;
1025
1056
  }
1026
1057
  }
1027
- checks.push({ name: 'vault_writable', pass: writable, detail: writable ? 'yes' : 'no' });
1058
+ checks.push({
1059
+ name: 'vault_writable',
1060
+ pass: writable,
1061
+ detail: writable ? 'yes' : generalVaultExists ? 'vault root not writable' : 'vault directory missing',
1062
+ });
1028
1063
  if (writable) score += 10;
1029
1064
 
1030
1065
  // Git clean?
1066
+ // v5.5.2 — In managed/linked mode, the git repo lives at vaultRoot, not
1067
+ // projectVaultRoot. Use vaultRoot for git status; projectVaultRoot is only
1068
+ // a subdirectory of the repo.
1031
1069
  let gitClean = null;
1032
- if ((mode === 'managed' || mode === 'linked') && vaultExists && isGitInstalled()) {
1033
- const gs = gitStatus(projectVaultRoot);
1070
+ if ((mode === 'managed' || mode === 'linked') && generalVaultExists && isGitInstalled()) {
1071
+ const gs = gitStatus(vaultRoot);
1034
1072
  gitClean = gs.clean;
1035
1073
  checks.push({
1036
1074
  name: 'git_clean',
@@ -1064,7 +1102,7 @@ export function createMemoryRouter({ projectRoot }) {
1064
1102
 
1065
1103
  // Schema valid?
1066
1104
  let invalidCount = 0;
1067
- if (vaultExists) {
1105
+ if (generalVaultExists) {
1068
1106
  const validationResults = validateAll(projectRoot);
1069
1107
  invalidCount = validationResults.length;
1070
1108
  }
@@ -1073,11 +1111,11 @@ export function createMemoryRouter({ projectRoot }) {
1073
1111
  pass: invalidCount === 0,
1074
1112
  detail: invalidCount === 0 ? 'all notes valid' : `${invalidCount} invalid`,
1075
1113
  });
1076
- if (invalidCount === 0 && vaultExists) score += 15;
1114
+ if (invalidCount === 0 && generalVaultExists) score += 15;
1077
1115
 
1078
1116
  // Secrets clean?
1079
1117
  let highFindings = 0;
1080
- if (vaultExists) {
1118
+ if (generalVaultExists) {
1081
1119
  for (const n of listNotes(projectRoot)) {
1082
1120
  const r = scanForSecrets(projectRoot, n.relPath);
1083
1121
  if (!r.safe) {
@@ -476,6 +476,23 @@ export async function createServer({
476
476
  console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
477
477
  }
478
478
 
479
+ // v5.5.2 — Auto-migrate legacy git.repoPath from the old vault location
480
+ // to the new default. Runs before the lightrag hook so git-dependent
481
+ // checks are already correct.
482
+ try {
483
+ const { migrateLegacyGitRepoPath } = await import('./memory-store.mjs');
484
+ const result = migrateLegacyGitRepoPath(projectRoot);
485
+ if (result.migrated) {
486
+ console.warn(
487
+ `[bizar-dash] git.repoPath auto-migrated:\n` +
488
+ ` from: ${result.from}\n` +
489
+ ` to: ${result.to}`,
490
+ );
491
+ }
492
+ } catch (err) {
493
+ console.warn('[bizar-dash] git.repoPath migration check failed:', err?.message || err);
494
+ }
495
+
479
496
  // v5.x — LightRAG startup hook (issue #6). Mirrors the headroom hook:
480
497
  // reads config from .bizar/memory.json + env vars, then calls
481
498
  // lightragStartupHook() which respects `lightrag.enabled` and the
@@ -203,4 +203,36 @@ describe('lightragStartupHook — config resolution', () => {
203
203
  });
204
204
  });
205
205
 
206
+ describe('lightragStartupHook — LLM unavailable but still starts', () => {
207
+ test('hook still returns ok=true when LLM is unreachable but binary is found', async () => {
208
+ // This test verifies the v5.5.2 fix: when detectAvailableLLM returns
209
+ // null (ollama unreachable + no API key), the hook used to return
210
+ // ok=false without attempting to start. Now it logs a warning but
211
+ // still tries to start. We verify the hook returns a structured
212
+ // result without throwing.
213
+ writeMemoryJson(tmpRoot, {
214
+ lightrag: {
215
+ enabled: true,
216
+ // Port unlikely to be in use — we pre-create a PID file to
217
+ // short-circuit the actual start probe.
218
+ port: 29997,
219
+ workingDir: join(tmpRoot, '.bizar', 'lightrag'),
220
+ },
221
+ });
222
+ mkdirSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true });
223
+ // Pre-create PID file pointing to a live PID so isRunning short-circuits
224
+ // to "already-running" without spawning.
225
+ writeFileSync(join(tmpRoot, '.bizar', 'lightrag', 'lightrag.pid'), String(process.pid));
226
+ try {
227
+ const r = await lightragStartupHook(tmpRoot);
228
+ // With the fix: hook returns ok=true, reason='already-running' even
229
+ // when LLM probe would have failed (no API key, ollama unreachable).
230
+ assert.equal(r.ok, true, 'hook should succeed even when LLM is unreachable');
231
+ assert.equal(r.reason, 'already-running');
232
+ } finally {
233
+ rmSync(join(tmpRoot, '.bizar', 'lightrag'), { recursive: true, force: true });
234
+ }
235
+ });
236
+ });
237
+
206
238
  console.log(' lightrag-startup-hook tests loaded');
@@ -9,8 +9,11 @@ import assert from 'node:assert';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
12
+ import { execSync, execFileSync } from 'node:child_process';
12
13
 
13
14
  const TEST_MEMORY_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
15
+ const { DEFAULT_MEMORY_VAULT } = TEST_MEMORY_STORE;
16
+ const TEST_GIT = await import('../src/server/memory-git.mjs').then((m) => m);
14
17
 
15
18
  describe('memory-store', () => {
16
19
  let projectRoot;
@@ -388,4 +391,103 @@ describe('memory-store', () => {
388
391
  assert.strictEqual(ok, false);
389
392
  });
390
393
  });
394
+
395
+ // ── migrateLegacyGitRepoPath ────────────────────────────────────────────
396
+
397
+ describe('migrateLegacyGitRepoPath', () => {
398
+ it('returns migrated=false when config does not exist', () => {
399
+ // projectRoot has no .bizar/memory.json
400
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
401
+ assert.strictEqual(r.migrated, false);
402
+ });
403
+
404
+ it('returns migrated=false when git.repoPath is not set', () => {
405
+ TEST_MEMORY_STORE.saveConfig(projectRoot, {
406
+ version: 1,
407
+ mode: 'managed',
408
+ projectId: 'test-proj',
409
+ memoryRepo: { mode: 'managed', path: null },
410
+ });
411
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
412
+ assert.strictEqual(r.migrated, false);
413
+ });
414
+
415
+ it('returns migrated=false when legacy path does not exist and new path also does not exist', () => {
416
+ TEST_MEMORY_STORE.saveConfig(projectRoot, {
417
+ version: 1,
418
+ mode: 'managed',
419
+ projectId: 'test-proj',
420
+ git: { repoPath: '/nonexistent/legacy/path' },
421
+ memoryRepo: { mode: 'managed', path: '/nonexistent/legacy/path' },
422
+ });
423
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
424
+ assert.strictEqual(r.migrated, false);
425
+ });
426
+
427
+ it('returns migrated=false when legacy path does not exist but new path is not a git repo', () => {
428
+ // Use the same path the migration function checks, but don't init git
429
+ const newDefault = join(DEFAULT_MEMORY_VAULT, 'bizar-memory');
430
+ mkdirSync(newDefault, { recursive: true });
431
+ try {
432
+ TEST_MEMORY_STORE.saveConfig(projectRoot, {
433
+ version: 1,
434
+ mode: 'managed',
435
+ projectId: 'test-proj',
436
+ git: { repoPath: '/nonexistent/legacy/path' },
437
+ memoryRepo: { mode: 'managed', path: '/nonexistent/legacy/path' },
438
+ });
439
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
440
+ assert.strictEqual(r.migrated, false);
441
+ } finally {
442
+ rmSync(newDefault, { recursive: true, force: true });
443
+ }
444
+ });
445
+
446
+ it('migrates when legacy path does not exist but new path is a git repo', () => {
447
+ // newDefault = join(DEFAULT_MEMORY_VAULT, 'bizar-memory') in the migration function
448
+ const newDefault = join(DEFAULT_MEMORY_VAULT, 'bizar-memory');
449
+ mkdirSync(newDefault, { recursive: true });
450
+ // Init git in the actual path the migration function checks
451
+ try {
452
+ execFileSync('git', ['init', '-b', 'main'], { cwd: newDefault, encoding: 'utf8', stdio: 'pipe' });
453
+ } catch { /* ignore — git may not be available in test env */ }
454
+ try {
455
+ TEST_MEMORY_STORE.saveConfig(projectRoot, {
456
+ version: 1,
457
+ mode: 'managed',
458
+ projectId: 'test-proj',
459
+ git: { repoPath: '/nonexistent/legacy/path' },
460
+ memoryRepo: { mode: 'managed', path: '/nonexistent/legacy/path' },
461
+ });
462
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
463
+ assert.strictEqual(r.migrated, true);
464
+ assert.ok(r.from.includes('/nonexistent/legacy/path'));
465
+ assert.strictEqual(r.to, newDefault);
466
+
467
+ // Verify config was updated
468
+ const updated = TEST_MEMORY_STORE.loadConfig(projectRoot);
469
+ assert.strictEqual(updated.config.git?.repoPath, newDefault);
470
+ } finally {
471
+ rmSync(newDefault, { recursive: true, force: true });
472
+ }
473
+ });
474
+
475
+ it('returns migrated=false when legacy path already exists (no need to migrate)', () => {
476
+ const legacyPath = join(tmpdir(), `bizar-legacy-exists-${Date.now()}-${Math.random().toString(36).slice(2)}`);
477
+ mkdirSync(legacyPath, { recursive: true });
478
+ try {
479
+ TEST_MEMORY_STORE.saveConfig(projectRoot, {
480
+ version: 1,
481
+ mode: 'managed',
482
+ projectId: 'test-proj',
483
+ git: { repoPath: legacyPath },
484
+ memoryRepo: { mode: 'managed', path: legacyPath },
485
+ });
486
+ const r = TEST_MEMORY_STORE.migrateLegacyGitRepoPath(projectRoot);
487
+ assert.strictEqual(r.migrated, false);
488
+ } finally {
489
+ rmSync(legacyPath, { recursive: true, force: true });
490
+ }
491
+ });
492
+ });
391
493
  });
@@ -176,3 +176,78 @@ describe('chatCompletion records usage', () => {
176
176
  }
177
177
  });
178
178
  });
179
+
180
+ // ── fetchRemains error handling ────────────────────────────────────────────
181
+
182
+ describe('fetchRemains — HTTP error messages (v5.5.2 fix)', () => {
183
+ // Force a unique HOME so the test doesn't conflict with other minimax tests
184
+ // that write to the same sandbox directory.
185
+ const SANDBOX = mkdtempSync(join(tmpdir(), `bizar-minimax-404-${Date.now()}-`));
186
+ const ORIG_HOME = process.env.HOME;
187
+ const ORIG_BIZAR_STORE_HOME = process.env.BIZAR_STORE_HOME;
188
+
189
+ before(() => {
190
+ process.env.HOME = SANDBOX;
191
+ process.env.BIZAR_STORE_HOME = join(SANDBOX, '.config', 'bizar');
192
+ mkdirSync(process.env.BIZAR_STORE_HOME, { recursive: true });
193
+ });
194
+
195
+ after(() => {
196
+ process.env.HOME = ORIG_HOME;
197
+ process.env.BIZAR_STORE_HOME = ORIG_BIZAR_STORE_HOME ?? '';
198
+ try { rmSync(SANDBOX, { recursive: true, force: true }); } catch { /* ignore */ }
199
+ });
200
+
201
+ async function mockRemains(url, status, body) {
202
+ const originalFetch = globalThis.fetch;
203
+ globalThis.fetch = function mock(urlOrReq, opts) {
204
+ const u = typeof urlOrReq === 'string' ? urlOrReq : urlOrReq?.url || String(urlOrReq);
205
+ if (u.includes('/token_plan/remains')) {
206
+ return Promise.resolve({
207
+ ok: false,
208
+ status,
209
+ headers: { get: () => 'application/json' },
210
+ statusText: status === 404 ? 'Not Found' : status === 401 ? 'Unauthorized' : status === 429 ? 'Too Many Requests' : 'Error',
211
+ text: () => Promise.resolve(JSON.stringify(body || {})),
212
+ });
213
+ }
214
+ return originalFetch.call(this, urlOrReq, opts);
215
+ };
216
+ try {
217
+ // Import with cache-bust to get fresh module state with the new HOME.
218
+ const { fetchRemains: fr } = await import(`../src/server/minimax.mjs?v=${Date.now()}`);
219
+ return await fr({ force: true });
220
+ } finally {
221
+ globalThis.fetch = originalFetch;
222
+ }
223
+ }
224
+
225
+ it('404 returns a descriptive message (not just "Not Found")', async () => {
226
+ const result = await mockRemains('/token_plan/remains', 404, {});
227
+ assert.equal(result.ok, false);
228
+ assert.equal(result.error, 'http_404');
229
+ assert.ok(
230
+ result.message.includes('endpoint may have changed') ||
231
+ result.message.includes('BizarHarness update'),
232
+ `Expected descriptive 404 message, got: ${result.message}`,
233
+ );
234
+ });
235
+
236
+ it('401/403 returns "invalid or expired key" message', async () => {
237
+ const result = await mockRemains('/token_plan/remains', 401, {});
238
+ assert.equal(result.ok, false);
239
+ assert.ok(
240
+ result.message.includes('invalid or expired'),
241
+ `Expected "invalid or expired" message, got: ${result.message}`,
242
+ );
243
+ });
244
+
245
+ it('429 returns "rate limit" message', async () => {
246
+ const result = await mockRemains('/token_plan/remains', 429, {});
247
+ assert.equal(result.ok, false);
248
+ assert.ok(
249
+ result.message.includes('rate limit'),
250
+ `Expected "rate limit" message, got: ${result.message}`,
251
+ );
252
+ });
253
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * cli/plow-through.test.mjs
3
+ *
4
+ * Tests for the /plow-through slash command file.
5
+ */
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { readFileSync, existsSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const PROJECT_ROOT = join(__dirname, '..');
14
+ const CMD_PATH = join(PROJECT_ROOT, 'config', 'commands', 'plow-through.md');
15
+
16
+ describe('/plow-through command file', () => {
17
+ test('file exists at config/commands/plow-through.md', () => {
18
+ assert.equal(existsSync(CMD_PATH), true, 'plow-through.md must exist');
19
+ });
20
+
21
+ test('YAML frontmatter has description field', () => {
22
+ const content = readFileSync(CMD_PATH, 'utf8');
23
+ assert.match(content, /^---\ndescription:/m, 'frontmatter must have description');
24
+ });
25
+
26
+ test('YAML frontmatter has agent field set to odin', () => {
27
+ const content = readFileSync(CMD_PATH, 'utf8');
28
+ assert.match(content, /^---\n.*\nagent: odin$/ms, 'frontmatter agent must be odin');
29
+ });
30
+
31
+ test('body has content (more than 5 lines)', () => {
32
+ const content = readFileSync(CMD_PATH, 'utf8');
33
+ // Strip frontmatter
34
+ const body = content.replace(/^---[\s\S]*?---\n/, '');
35
+ const lines = body.trim().split('\n').filter(l => l.trim().length > 0);
36
+ assert.ok(lines.length > 5, `body should have content, got ${lines.length} non-empty lines`);
37
+ });
38
+
39
+ test('body contains autonomous-mode contract keywords', () => {
40
+ const content = readFileSync(CMD_PATH, 'utf8');
41
+ const body = content.replace(/^---[\s\S]*?---\n/, '');
42
+ const lower = body.toLowerCase();
43
+ assert.ok(lower.includes('no clarifying questions') || lower.includes('no clarifying'), 'body must state no clarifying questions');
44
+ assert.ok(lower.includes('parallel') || lower.includes('dispatch'), 'body must mention parallel dispatch');
45
+ assert.ok(lower.includes('work to completion') || lower.includes('complete'), 'body must state work-to-completion');
46
+ });
47
+
48
+ test('body contains when NOT to use section', () => {
49
+ const content = readFileSync(CMD_PATH, 'utf8');
50
+ const lower = content.toLowerCase();
51
+ assert.ok(lower.includes('when not to use'), 'body must have a when NOT to use section');
52
+ });
53
+ });
54
+
55
+ console.log(' plow-through.test.mjs loaded — run with: node --test cli/plow-through.test.mjs');
@@ -0,0 +1,47 @@
1
+ ---
2
+ description: Autonomous mode — work fully independently, make all decisions, complete the task end-to-end without asking the user anything.
3
+ agent: odin
4
+ ---
5
+
6
+ # Plow Through — Autonomous Mode
7
+
8
+ You are in `/plow-through` mode. The user has invoked this command to tell you: **work autonomously, don't ask, decide things yourself, complete the task end-to-end.**
9
+
10
+ ## Contract
11
+
12
+ - **No clarifying questions.** If the request is ambiguous, use the most reasonable interpretation based on project context. If you genuinely cannot proceed without user input (e.g., a destructive action requiring explicit authorization), log the blocker in your final report and continue with everything else.
13
+ - **Decide things yourself.** Use your judgment. Read `.bizar/PROJECT.md`, `FINAL_GOAL.md`, `ROADMAP.md`, and search the memory vault (`bizar memory search "<topic>"`) for prior context before deciding.
14
+ - **Split into parallel work streams.** Always dispatch 2+ subagents in parallel when the work is decomposable. Each stream must have a disjoint file scope.
15
+ - **Work to completion.** Don't stop at "I did X, should I continue?". The task is complete when: the deliverable exists, tests pass, changes are committed and pushed (where applicable).
16
+ - **Report at the end.** Summarize what was done, what tests ran, any blockers encountered.
17
+
18
+ ## Background agents for long-running work
19
+
20
+ Use `bizar_spawn_background` (or the dashboard UI) to spawn background agents for tasks that span more than a few minutes. Check on them later via `bizar_status` or the Background Agents panel.
21
+
22
+ ## When to use
23
+
24
+ - Multi-file refactors that don't require user approval
25
+ - Bug-fix sweeps across a known surface area
26
+ - Implementing a clearly-spec'd feature from the roadmap
27
+ - Migration tasks (e.g., "migrate all CSS from @apply to vanilla")
28
+ - Cleanup work (rename X, delete dead code Y, etc.)
29
+
30
+ ## When NOT to use
31
+
32
+ - Anything that touches auth, billing, or destructive operations on user data
33
+ - Architectural decisions with multiple valid approaches (use `/plan` first)
34
+ - Tasks where you genuinely need user input on a key decision
35
+ - Anything where being wrong has high consequences (merges to main, security patches)
36
+
37
+ ## Execution pattern
38
+
39
+ 1. Read project context (`.bizar/PROJECT.md`, `FINAL_GOAL.md`, `ROADMAP.md`)
40
+ 2. Search memory for prior context (`bizar memory search "<topic>"`)
41
+ 3. Decompose into independent work streams
42
+ 4. Dispatch streams via `task` to @thor and @tyr in parallel
43
+ 5. After streams return: run test gate
44
+ 6. Fix any test failures
45
+ 7. Update self-improvement log (`.bizar/AGENTS_SELF_IMPROVEMENT.md`)
46
+ 8. Commit + push (delegated to @hermod)
47
+ 9. Report final outcome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "5.5.4",
3
+ "version": "5.5.6",
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": {