@polderlabs/bizar 10.16.0 → 10.16.2

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.
@@ -19,9 +19,11 @@ export function showInstallHelp() {
19
19
  Usage:
20
20
  bizar install Install (or refresh) every component
21
21
  bizar install --dry-run Print what would happen, change nothing
22
- bizar install --force Overwrite existing files AND prune stale
23
- entries in ~/.claude/{agents,skills,
24
- commands,rules,hooks}
22
+ bizar install --force Full clean install: wipe Bizar-managed dirs,
23
+ back up settings env vars, re-sync everything
24
+ from the repo. Preserves ~/.config/bizar/
25
+ login state. Combine with --yes to skip prompts.
26
+ bizar install --deep Alias for --force (clean-install semantics)
25
27
  bizar install --yes Assume yes for any non-destructive prompt
26
28
  bizar install --help Show this help
27
29
 
@@ -30,6 +32,21 @@ export function showInstallHelp() {
30
32
  difference is just mode=install vs mode=update. Every step is
31
33
  idempotent — running this twice is safe.
32
34
 
35
+ F-183 (v10.16.2+) — --force promotes the install from
36
+ overwrite+prune-stale to a fully clean install. It wipes:
37
+ - ~/.claude/{agents,skills,commands,hooks,rules,workflows,plugins}/
38
+ - ~/.agents/ (shared skill-registry + lock)
39
+ - ~/.claude/settings.json (env vars backed up to BIZAR_SAVED_ENV)
40
+ and preserves:
41
+ - ~/.config/bizar/ (login state, telemetry, model picks, worktree-queue)
42
+ - ~/.claude/.credentials.json, statsig/, .playwright-mcp/
43
+ - any user-created subdirs under ~/.claude/ outside the managed set
44
+
45
+ The freshly-emitted settings.json inherits the F-181 wildcard
46
+ permissions.allow expansion and the F-176 empty deny/ask lists
47
+ while the prior ANTHROPIC_* and BIZAR_* env vars are merged back
48
+ in from the stash.
49
+
33
50
  1. Installs @polderlabs/bizar via npm (skipped if already current).
34
51
  2. Shells to ./install.sh for platform-specific system dependencies.
35
52
  3. Syncs agent files, slash commands, and bundled skills into
@@ -38,7 +55,6 @@ export function showInstallHelp() {
38
55
  5. Wires Claude Code lifecycle hooks (SessionStart / PreToolUse /
39
56
  PostToolUse / UserPromptSubmit) under ~/.claude/hooks/.
40
57
  6. Runs 'bizar doctor' as a post-install health check.
41
-
42
58
  No API key collection, no interactive prompts.
43
59
  `);
44
60
  }
@@ -48,7 +64,6 @@ export function showUpdateHelp() {
48
64
  bizar update — Update @anthropic-ai/claude-code + @polderlabs/bizar
49
65
  (which bundles the CLI, SDK, agents, skills, hooks, and commands). Detects what's
50
66
  installed and only touches what's missing or out of date.
51
-
52
67
  Usage:
53
68
  bizar update Update all installed Bizar components
54
69
  bizar update --check Only print current vs. latest; do not update
@@ -57,11 +72,9 @@ export function showUpdateHelp() {
57
72
  bizar update --force Override .bizar/PRE_PUSH_NOTES.md blockers
58
73
  bizar update --yes Same as --force, but named for one-line scripts
59
74
  bizar update --help Show this help
60
-
61
75
  Components updated:
62
76
  @anthropic-ai/claude-code the Claude Code CLI itself
63
77
  @polderlabs/bizar CLI + SDK + agents + skills + hooks
64
-
65
78
  Behavior (v4.4.7+):
66
79
  • Single unified provisioner. 'bizar install' and 'bizar update' are
67
80
  the same code path with different mode flags. Every step is
@@ -72,13 +85,11 @@ export function showUpdateHelp() {
72
85
  regressions before claude tries to start.
73
86
  • With --check: prints the version matrix and release-notes excerpt
74
87
  between current and latest, exits non-zero if an update is available.
75
-
76
88
  Examples:
77
89
  bizar update Full auto-update (recommended)
78
90
  bizar update --check Show version matrix + notes, do nothing
79
91
  bizar update --channel=beta Upgrade to latest beta build
80
92
  bizar update --dry-run Preview what would change
81
-
82
93
  Errors:
83
94
  Network failures (registry offline / DNS) and npm permission issues
84
95
  are surfaced with the raw npm output. The provisioner never silently
@@ -87,7 +98,6 @@ export function showUpdateHelp() {
87
98
  }
88
99
 
89
100
  // ── Command runners ────────────────────────────────────────────────────────────
90
-
91
101
  export async function install(args, isHelpRequest) {
92
102
  if (isHelpRequest) {
93
103
  showInstallHelp();
@@ -95,9 +105,21 @@ export async function install(args, isHelpRequest) {
95
105
  }
96
106
  // parseFlags lives in cli/provision.mjs and is the canonical argv
97
107
  // parser for the installer family. Reusing it keeps install and
98
- // update in lockstep on flag semantics.
108
+ // update in lockstep on flag semantics. --deep is parsed as an
109
+ // alias for --force (clean-install semantics, F-183).
99
110
  const { mode, dryRun, force, yes } = parseFlags(args);
100
- await runInstaller({ mode, dryRun, force, yes });
111
+ const result = await runInstaller({ mode, dryRun, force, yes });
112
+ // F-183 — print a one-line summary of the wipe scope so operators
113
+ // can see at a glance what changed without re-reading the verbose
114
+ // step list.
115
+ if (force && result?.clean) {
116
+ console.log('');
117
+ console.log(chalk.cyan(` Summary (F-183): wiped ${result.clean.wiped.length} path(s); preserved ${result.clean.preserved.length} path(s).`));
118
+ if (result.doctor) {
119
+ const ok = result.doctor.failed === 0;
120
+ console.log(chalk[ok ? 'green' : 'yellow'](` Doctor: ${result.doctor.passed} passed, ${result.doctor.failed} failed.`));
121
+ }
122
+ }
101
123
  // v4.4.3 — After install, repair any stale bin symlinks so the
102
124
  // user picks up the new code.
103
125
  try {
@@ -5,15 +5,29 @@
5
5
  * that delegates to the provisioner.
6
6
  */
7
7
 
8
- import { runProvision } from '../provision.mjs';
8
+ import { runProvision, forceCleanInstall, clearSavedEnv } from '../provision.mjs';
9
+ import { runDoctor } from '../doctor.mjs';
9
10
  import { showBanner, sectionHeading } from './banner.mjs';
10
11
  import { printInstallLocations } from './paths.mjs';
11
12
 
12
13
  /**
13
14
  * Thin orchestrator entry point.
15
+ *
16
+ * Behavior (v10.16.2+, F-183):
17
+ * - When `force === true`, run `forceCleanInstall` *before*
18
+ * `runProvision` so the Bizar-managed dirs under `~/.claude/` and
19
+ * `~/.agents/` are wiped, `~/.claude/settings.json` is removed, and
20
+ * the prior env vars are stashed into `process.env.BIZAR_SAVED_ENV`
21
+ * for the factory to re-inject. We always pass `force: true` to
22
+ * `runProvision` regardless of how the caller phrased the flag.
23
+ * - After the provisioner completes, run `runDoctor({ silent: true })`
24
+ * and surface the result so forced installs surface a health summary.
25
+ * - The wipe report is returned to the caller (e.g. `bizar install`)
26
+ * so the CLI layer can print a summary of what changed.
27
+ *
14
28
  * @param {object} opts
15
29
  * @param {boolean} [opts.dryRun]
16
- * @param {boolean} [opts.force] - overwrite existing files AND prune stale entries
30
+ * @param {boolean} [opts.force] - overwrite existing files AND prune stale entries AND wipe dirs (F-183)
17
31
  * @param {boolean} [opts.quiet] - Only print the location card
18
32
  * @param {string} [opts.mode] - 'install' | 'update'
19
33
  * @param {boolean} [opts.yes] - assume yes for any non-destructive prompts
@@ -29,5 +43,56 @@ export async function runInstaller(opts = {}) {
29
43
  showBanner();
30
44
  printInstallLocations({ dryRun, force });
31
45
 
32
- return runProvision({ mode, dryRun, force, yes });
33
- }
46
+ // F-183 pre-provision wipe for forced installs. Force-clean is what
47
+ // makes `--force` actually a clean install: dirs under ~/.claude/ are
48
+ // wiped (BIZAR_HOME and third-party state preserved), settings.json
49
+ // is removed, and the operator's env vars are stashed in
50
+ // `process.env.BIZAR_SAVED_ENV` so the next `writeClaudeSettings` call
51
+ // re-injects them after re-emitting the file from the template.
52
+ let clean = null;
53
+ if (force) {
54
+ clearSavedEnv();
55
+ clean = forceCleanInstall({ dryRun });
56
+ if (!quiet && clean?.wiped?.length) {
57
+ sectionHeading('Pre-install wipe (F-183)');
58
+ console.log(` wiped: ${clean.wiped.length} path(s)`);
59
+ for (const p of clean.wiped) console.log(` - ${p}`);
60
+ if (clean.preserved?.length) {
61
+ console.log(` preserved: ${clean.preserved.length} path(s)`);
62
+ for (const p of clean.preserved) console.log(` - ${p}`);
63
+ }
64
+ console.log('');
65
+ }
66
+ }
67
+
68
+ // Always pass `force: true` downstream so `runProvision` re-emits the
69
+ // template-owned keys (permissions.allow wildcards, mcpServers, hooks)
70
+ // into the freshly-empty settings file.
71
+ const provisionResult = await runProvision({ mode, dryRun, force: true, yes });
72
+
73
+ // F-183 — post-install health check. Surfaced as a warning rather
74
+ // than a hard failure so a forced install that completes without
75
+ // error still reports its doctor summary; the operator decides
76
+ // whether to investigate.
77
+ if (!dryRun) {
78
+ try {
79
+ const doctorResult = await runDoctor({ silent: true });
80
+ provisionResult.doctor = doctorResult;
81
+ if (!quiet) {
82
+ sectionHeading('Post-install doctor (F-183)');
83
+ const ok = doctorResult.failed === 0;
84
+ console.log(` ${ok ? '✓' : '✗'} doctor: ${doctorResult.passed} passed, ${doctorResult.failed} failed`);
85
+ if (doctorResult.failed > 0) {
86
+ for (const r of doctorResult.results) {
87
+ if (!r.ok) console.log(` ✗ ${r.name}: ${r.message}`);
88
+ }
89
+ }
90
+ console.log('');
91
+ }
92
+ } catch (err) {
93
+ if (!quiet) console.log(` ! doctor skipped: ${err?.message || err}`);
94
+ }
95
+ }
96
+
97
+ return { ...provisionResult, clean };
98
+ }
package/cli/provision.mjs CHANGED
@@ -740,7 +740,41 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
740
740
  const shipped = readJsonSafe(join(REPO_ROOT, 'config', 'claude', 'settings.json'), {}) || {};
741
741
  const shippedRouter = readJsonSafe(join(REPO_ROOT, 'config', 'claude', 'model-router.json'), {}) || {};
742
742
  const defaultGatewayUrl = shippedRouter.endpoint || shippedRouter.gateway?.endpoint || 'http://localhost:20129/v1';
743
- const gatewayUrl = process.env.ANTHROPIC_BASE_URL
743
+
744
+ // F-183 — when `forceCleanInstall` has wiped `~/.claude/settings.json`,
745
+ // it stashes the prior env block into `process.env.BIZAR_SAVED_ENV`.
746
+ // The stash is the **only** source of operator credentials that
747
+ // survives a wipe — everything else on disk has been removed by the
748
+ // time this function runs. We honor it preferentially for the env
749
+ // keys in FORCE_CLEAN_PRESERVE_ENV_KEYS so `bizar install --force`
750
+ // preserves gateway URLs / auth tokens without forcing operators to
751
+ // re-export them in the shell.
752
+ //
753
+ // Precedence (preserved-env keys):
754
+ // savedEnv.X > process.env.X > existingEnv.X > default
755
+ //
756
+ // The stash is only consulted when it was actually set by
757
+ // `forceCleanInstall` (non-empty JSON). For direct `force: true`
758
+ // calls that bypass the wipe, we keep the legacy precedence so the
759
+ // existing merge-settings contract is unchanged.
760
+ let savedEnv = {};
761
+ if (typeof process.env.BIZAR_SAVED_ENV === 'string') {
762
+ try { savedEnv = JSON.parse(process.env.BIZAR_SAVED_ENV) || {}; } catch { /* corrupt stash */ }
763
+ }
764
+ const hasSavedEnv = Object.keys(savedEnv).length > 0;
765
+
766
+ const pickEnv = (key) => {
767
+ if (hasSavedEnv && typeof savedEnv[key] === 'string') return savedEnv[key];
768
+ if (process.env[key]) return process.env[key];
769
+ if (!force && typeof existingEnv[key] === 'string') return existingEnv[key];
770
+ return undefined;
771
+ };
772
+
773
+ const savedBaseUrl = pickEnv('ANTHROPIC_BASE_URL');
774
+ const savedRouterUrl = pickEnv('BIZAR_MODEL_ROUTER_URL');
775
+ const gatewayUrl = savedBaseUrl
776
+ || savedRouterUrl
777
+ || process.env.ANTHROPIC_BASE_URL
744
778
  || process.env.BIZAR_MODEL_ROUTER_URL
745
779
  || (!force && (existingEnv.ANTHROPIC_BASE_URL || existingEnv.BIZAR_MODEL_ROUTER_URL))
746
780
  || defaultGatewayUrl;
@@ -808,12 +842,20 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
808
842
  env: {
809
843
  BIZAR_HOME: BIZAR_HOME(),
810
844
  ANTHROPIC_BASE_URL: gatewayUrl,
811
- BIZAR_MODEL_ROUTER_URL: process.env.BIZAR_MODEL_ROUTER_URL || gatewayUrl,
812
- ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || 'sk_9router',
845
+ BIZAR_MODEL_ROUTER_URL:
846
+ pickEnv('BIZAR_MODEL_ROUTER_URL')
847
+ || gatewayUrl,
848
+ ANTHROPIC_AUTH_TOKEN:
849
+ pickEnv('ANTHROPIC_AUTH_TOKEN')
850
+ || 'sk_9router',
813
851
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY:
814
- process.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY || '1',
852
+ pickEnv('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY')
853
+ || '1',
815
854
  CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS:
816
- process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS || shipped.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS || '1',
855
+ pickEnv('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS')
856
+ || shipped.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
857
+ || '1',
858
+ ...(pickEnv('ANTHROPIC_MODEL') ? { ANTHROPIC_MODEL: pickEnv('ANTHROPIC_MODEL') } : {}),
817
859
  },
818
860
  hooks: {
819
861
  UserPromptSubmit: [{ hooks: [hook('user-prompt-submit', 10)] }],
@@ -859,6 +901,12 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
859
901
  if (dryRun) return { ok: true, message: `[dry-run] would write ${fp}` };
860
902
  ensureDir(CLAUDE_DIR);
861
903
  writeFileSync(fp, JSON.stringify(merged, null, 2) + '\n');
904
+ // F-183 — clear the in-process stash once consumed so subsequent
905
+ // writes in the same run (or in tests) don't accidentally inherit
906
+ // operator credentials.
907
+ if (typeof process.env.BIZAR_SAVED_ENV === 'string') {
908
+ delete process.env.BIZAR_SAVED_ENV;
909
+ }
862
910
  return { ok: true, message: `wrote ${fp}`, path: fp };
863
911
  }
864
912
 
@@ -1164,6 +1212,164 @@ export async function syncConfigExtras({ dryRun = false } = {}) {
1164
1212
  return { ok: true, message: `synced (${counts.commands} commands, ${counts.skills} skills, ${counts.hooks} hooks, ${counts.rules} rules, ${counts.workflows} workflows)`, counts };
1165
1213
  }
1166
1214
 
1215
+ // ─── F-183 — fully clean install ────────────────────────────────────────────
1216
+
1217
+ /**
1218
+ * Env keys whose values the freshly-emitted `~/.claude/settings.json`
1219
+ * must inherit from a previously-installed copy of the file. Operators
1220
+ * run `bizar install --force` to repair drifted settings; this set is
1221
+ * the contract that keeps gateway credentials + provider URLs alive
1222
+ * across the wipe.
1223
+ *
1224
+ * Anything outside this set is **not** preserved — the re-emitted
1225
+ * `permissions` block must mirror the current template so the F-181
1226
+ * wildcard expansion and F-176 empty deny/ask lists land on disk.
1227
+ */
1228
+ export const FORCE_CLEAN_PRESERVE_ENV_KEYS = Object.freeze([
1229
+ 'ANTHROPIC_BASE_URL',
1230
+ 'ANTHROPIC_AUTH_TOKEN',
1231
+ 'BIZAR_MODEL_ROUTER_URL',
1232
+ 'BIZAR_HOME',
1233
+ 'ANTHROPIC_MODEL',
1234
+ 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY',
1235
+ 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
1236
+ ]);
1237
+
1238
+ /**
1239
+ * Resolve `~/.agents/` (the shared skills-registry directory).
1240
+ * Honors `process.env.AGENTS_DIR` so tests and operators can override
1241
+ * it without bouncing HOME.
1242
+ */
1243
+ export function resolveAgentsDir() {
1244
+ if (process.env.AGENTS_DIR && process.env.AGENTS_DIR.trim()) {
1245
+ return process.env.AGENTS_DIR.trim();
1246
+ }
1247
+ return join(HOME, '.agents');
1248
+ }
1249
+
1250
+ /** `~/.agents/` — shared skills-registry directory. Re-evaluated per read so
1251
+ * tests that flip `process.env.AGENTS_DIR` are honored. */
1252
+ export function AGENTS_DIR() {
1253
+ return resolveAgentsDir();
1254
+ }
1255
+
1256
+ /**
1257
+ * Bizar-managed subdirectories of `~/.claude/`. Anything outside this
1258
+ * set under `~/.claude/` is treated as user-owned and is preserved
1259
+ * by `forceCleanInstall` (e.g. `.credentials.json`, `statsig/`,
1260
+ * `.playwright-mcp/`).
1261
+ */
1262
+ const FORCE_CLEAN_WIPE_DIRS = Object.freeze([
1263
+ 'agents',
1264
+ 'skills',
1265
+ 'commands',
1266
+ 'hooks',
1267
+ 'rules',
1268
+ 'workflows',
1269
+ 'plugins',
1270
+ ]);
1271
+
1272
+ /**
1273
+ * F-183 — wipe every Bizar-managed directory under `~/.claude/` plus
1274
+ * `~/.agents/`, back up `~/.claude/settings.json` env vars into
1275
+ * `process.env.BIZAR_SAVED_ENV`, and return a structured wipe report.
1276
+ *
1277
+ * The wipe is **idempotent** at the file-system level (rmSync is
1278
+ * recursive+force), and the resulting state is fully reproducible by
1279
+ * the subsequent `runProvision` call because the factory re-emits the
1280
+ * entire settings file from the shipped template (so the F-181
1281
+ * wildcard expansion in `permissions.allow` lands automatically).
1282
+ *
1283
+ * What is **not** wiped:
1284
+ * - `~/.config/bizar/` (BIZAR_HOME) — login state, telemetry,
1285
+ * worktree-queue, model picks, installed.json marker. Operators
1286
+ * rely on this surviving a forced reinstall.
1287
+ * - `~/.claude/.credentials.json`, `~/.claude/statsig/`,
1288
+ * `~/.claude/.playwright-mcp/` — third-party state managed by
1289
+ * Claude Code itself.
1290
+ * - Any subdirectory of `~/.claude/` not in FORCE_CLEAN_WIPE_DIRS.
1291
+ *
1292
+ * @param {{ dryRun?: boolean }} [opts]
1293
+ * @returns {{ ok: true, message: string, wiped: string[], preserved: string[], env: Record<string, string> }}
1294
+ */
1295
+ export function forceCleanInstall(opts = {}) {
1296
+ const { dryRun = false } = opts;
1297
+ const claudeDir = resolveClaudeDir();
1298
+ const agentsDir = resolveAgentsDir();
1299
+ const settingsPath = join(claudeDir, 'settings.json');
1300
+
1301
+ // 1. Stash existing env vars so `writeClaudeSettings` can re-inject
1302
+ // them when the freshly-emitted file lands. Use a string env so
1303
+ // we don't pollute the parent process object graph with arbitrary
1304
+ // operator-defined keys.
1305
+ let savedEnv = {};
1306
+ if (existsSync(settingsPath)) {
1307
+ try {
1308
+ const cur = JSON.parse(readFileSync(settingsPath, 'utf8'));
1309
+ if (cur && typeof cur === 'object' && cur.env && typeof cur.env === 'object') {
1310
+ for (const key of FORCE_CLEAN_PRESERVE_ENV_KEYS) {
1311
+ if (typeof cur.env[key] === 'string') savedEnv[key] = cur.env[key];
1312
+ }
1313
+ }
1314
+ } catch { /* corrupt settings — treat as no env */ }
1315
+ }
1316
+ // Also pick up env values from the live process so a freshly-invoked
1317
+ // `bizar install --force` carries operator credentials through the
1318
+ // wipe even if the on-disk settings file is missing/stale.
1319
+ for (const key of FORCE_CLEAN_PRESERVE_ENV_KEYS) {
1320
+ if (!savedEnv[key] && process.env[key]) savedEnv[key] = process.env[key];
1321
+ }
1322
+ process.env.BIZAR_SAVED_ENV = JSON.stringify(savedEnv);
1323
+
1324
+ // 2. Wipe managed dirs.
1325
+ const wiped = [];
1326
+ for (const sub of FORCE_CLEAN_WIPE_DIRS) {
1327
+ const dir = join(claudeDir, sub);
1328
+ if (!existsSync(dir)) continue;
1329
+ wiped.push(dir);
1330
+ if (!dryRun) {
1331
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
1332
+ }
1333
+ }
1334
+ if (existsSync(agentsDir)) {
1335
+ wiped.push(agentsDir);
1336
+ if (!dryRun) {
1337
+ try { rmSync(agentsDir, { recursive: true, force: true }); } catch { /* ignore */ }
1338
+ }
1339
+ }
1340
+ // 3. Wipe settings.json so writeClaudeSettings re-emits from the
1341
+ // shipped template. Operators keep their gateway / auth env vars
1342
+ // via the BIZAR_SAVED_ENV stash.
1343
+ if (existsSync(settingsPath)) {
1344
+ wiped.push(settingsPath);
1345
+ if (!dryRun) {
1346
+ try { rmSync(settingsPath, { force: true }); } catch { /* ignore */ }
1347
+ }
1348
+ }
1349
+
1350
+ const preserved = [];
1351
+ if (existsSync(BIZAR_HOME())) preserved.push(BIZAR_HOME());
1352
+ // Document the third-party state we intentionally left alone.
1353
+ for (const sub of ['.credentials.json', 'statsig', '.playwright-mcp']) {
1354
+ const p = join(claudeDir, sub);
1355
+ if (existsSync(p)) preserved.push(p);
1356
+ }
1357
+
1358
+ const tag = dryRun ? '[dry-run] ' : '';
1359
+ const message = `${tag}F-183 clean: wiped ${wiped.length} paths; preserved ${preserved.length} paths (BIZAR_HOME + third-party state)`;
1360
+ return { ok: true, message, wiped, preserved, env: savedEnv };
1361
+ }
1362
+
1363
+ /**
1364
+ * Inverse of `forceCleanInstall` — clears the in-process stash once
1365
+ * `writeClaudeSettings` has consumed it. Idempotent and side-effect
1366
+ * free; exported so tests can run multiple forced installs without
1367
+ * leaking stale stashes between scenarios.
1368
+ */
1369
+ export function clearSavedEnv() {
1370
+ delete process.env.BIZAR_SAVED_ENV;
1371
+ }
1372
+
1167
1373
  // ─── CLI entry ──────────────────────────────────────────────────────────────
1168
1374
 
1169
1375
  export function parseFlags(argv) {
@@ -1177,7 +1383,7 @@ export function parseFlags(argv) {
1177
1383
  const v = argv[++i];
1178
1384
  if (v === 'install' || v === 'update' || v === 'install-only-system') opts.mode = v;
1179
1385
  } else if (a === '--dry-run') opts.dryRun = true;
1180
- else if (a === '--force') opts.force = true;
1386
+ else if (a === '--force' || a === '--deep') opts.force = true;
1181
1387
  else if (a === '--yes' || a === '-y') opts.yes = true;
1182
1388
  else if (a === '--non-interactive') opts.yes = true;
1183
1389
  else if (a === '--no-service') opts.start = false;
@@ -20,12 +20,12 @@ import { pathToFileURL } from 'node:url';
20
20
 
21
21
  import { loadModelRouter } from '../../../config/agents/model-assignment.mjs';
22
22
 
23
- function deny(reason) {
23
+ function advise(reason) {
24
24
  return {
25
25
  hookSpecificOutput: {
26
26
  hookEventName: 'PreToolUse',
27
- permissionDecision: 'deny',
28
- permissionDecisionReason: reason,
27
+ permissionDecision: 'allow',
28
+ additionalContext: `🟡 Model override guidance: ${reason} The dispatch will proceed regardless.`,
29
29
  },
30
30
  };
31
31
  }
@@ -80,7 +80,7 @@ export async function guardAgentModel(input, options = {}) {
80
80
 
81
81
  const allowed = configuredModels(registry);
82
82
  if (!allowed.has(requested)) {
83
- return deny(`Bizar Agent dispatch blocked: model override ${requested} is outside the configured dynamic tiers and the user-selected pool. Omit model to inherit the session or pick it via \`bizar models\`.`);
83
+ return advise(`Bizar Agent dispatch blocked: model override ${requested} is outside the configured dynamic tiers and the user-selected pool. Omit model to inherit the session or pick it via \`bizar models\`.`);
84
84
  }
85
85
 
86
86
  // User-selected models bypass live-discovery validation. The picker is the
@@ -89,7 +89,7 @@ export async function guardAgentModel(input, options = {}) {
89
89
  if (!fromUserPick && Array.isArray(options.availableModelIds)) {
90
90
  const available = new Set(options.availableModelIds);
91
91
  if (!available.has(requested)) {
92
- return deny(`Bizar Agent dispatch blocked: ${requested} was not reported by live discovery. Omit model to inherit the active session; do not retry aliases.`);
92
+ return advise(`Bizar Agent dispatch blocked: ${requested} was not reported by live discovery. Omit model to inherit the active session; do not retry aliases.`);
93
93
  }
94
94
  }
95
95
 
@@ -65,8 +65,8 @@ process.stdin.on('end', () => {
65
65
  process.stdout.write(JSON.stringify({
66
66
  hookSpecificOutput: {
67
67
  hookEventName: 'PreToolUse',
68
- permissionDecision: 'deny',
69
- permissionDecisionReason: `Humanize the text before publishing it: ${notes.join('; ')}.`,
68
+ permissionDecision: 'allow',
69
+ additionalContext: `🟡 Style suggestion: humanize the text before publishing. ${notes.join('; ')}. The write will proceed regardless.`,
70
70
  },
71
71
  }) + '\n');
72
72
  }
@@ -8,7 +8,7 @@
8
8
  * NOT consume the marker — the hook chain may run more than once for a single
9
9
  * bash invocation, and a one-shot rm caused sporadic false denies.
10
10
  *
11
- * Missing or stale markers deny the commit. Hard approval gates (commit,
11
+ * Missing or stale markers emit an advisory reminder. Hard approval gates (commit,
12
12
  * push, gh, publish, deploy) remain in git-workflow-guard.mjs.
13
13
  */
14
14
 
@@ -94,8 +94,8 @@ process.stdin.on('end', () => {
94
94
  process.stdout.write(JSON.stringify({
95
95
  hookSpecificOutput: {
96
96
  hookEventName: 'PreToolUse',
97
- permissionDecision: 'deny',
98
- permissionDecisionReason: 'Run /simplify on the staged diff, apply any justified cleanup, rerun tests, then retry the commit.',
97
+ permissionDecision: 'allow',
98
+ additionalContext: '🟡 /simplify not run on the current staged diff. Recommended: run /simplify, apply any justified cleanup, rerun tests, then retry the commit. The commit will proceed without /simplify if you choose.',
99
99
  },
100
100
  }) + '\n');
101
101
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.16.0",
3
+ "version": "10.16.2",
4
4
  "description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,12 @@ while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do
20
20
  if [ -z "$LOCAL_SHA" ] || [ "$LOCAL_SHA" = "0000000000000000000000000000000000000000" ]; then
21
21
  continue
22
22
  fi
23
+ # Tag pushes (refs/tags/*) point at existing commits; they don't introduce
24
+ # new file changes. Skip tag-only ref updates so an annotated tag pointing
25
+ # at a commit already pushed via master doesn't scan the full history.
26
+ case "$LOCAL_REF" in
27
+ refs/tags/*) continue ;;
28
+ esac
23
29
  if [ -z "$REMOTE_SHA" ] || [ "$REMOTE_SHA" = "0000000000000000000000000000000000000000" ]; then
24
30
  # New branch — scan all commits on the local side
25
31
  RANGE="$LOCAL_SHA"