@polderlabs/bizar 10.12.0 → 10.12.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.
@@ -10,7 +10,6 @@ import { spawnSync } from 'node:child_process';
10
10
  import { findGitCommand } from './git-command-parser.mjs';
11
11
 
12
12
  const ALLOWED_COMMIT_TYPES = ['feat', 'fix', 'refactor', 'docs', 'style', 'test', 'build', 'chore'];
13
- const AI_ATTRIBUTION = /Claude-Session:|Co-Authored-By:\s*(?:Claude|Codex|ChatGPT|OpenAI|Gemini|Cursor|Copilot)|Generated with[^\n]*Claude Code|claude\.ai\/code\/session/i;
14
13
  const GH_GLOBAL_OPTION = String.raw`(?:(?:-R|--repo|--hostname)\s+(?:"[^"]*"|'[^']*'|\S+)|--(?:help|version))`;
15
14
 
16
15
  function commandPattern(program, globalOption, subcommand) {
@@ -82,19 +81,24 @@ process.stdin.on('end', () => {
82
81
  output('deny', 'Rebasing rewrites history. Use a merge or follow-up commit unless the user explicitly changes project policy.');
83
82
  return;
84
83
  }
85
- if (AI_ATTRIBUTION.test(command) && (commit || hasGhCommand(command, 'pr', '\\w+'))) {
86
- output('deny', 'Remove AI-attribution trailers or generated-by links from the commit or pull-request text.');
87
- return;
88
- }
89
-
90
84
  if (commit) {
91
85
  const message = commitMessage(command);
92
86
  const subject = message.split('\n').find((line) => line.trim())?.trim() ?? '';
93
- if (subject && !new RegExp(`^(?:${ALLOWED_COMMIT_TYPES.join('|')})(\\([^)]+\\))?:\\s+\\S`, 'i').test(subject)) {
94
- output('deny', `Use a conventional commit subject: ${ALLOWED_COMMIT_TYPES.join(', ')} followed by ": description".`);
95
- return;
87
+ // F-145 loosening: subject-shape check is a soft warning attached to
88
+ // the same hook response as the `ask` decision (the dispatcher
89
+ // expects one JSON line per leaf).
90
+ const conventional = subject && !new RegExp(`^(?:${ALLOWED_COMMIT_TYPES.join('|')})(\\([^)]+\\))?:\\s+\\S`, 'i').test(subject);
91
+ const payload = {
92
+ hookSpecificOutput: {
93
+ hookEventName: 'PreToolUse',
94
+ permissionDecision: 'ask',
95
+ permissionDecisionReason: `Create this local commit${subject ? `: ${subject}` : ''}?`,
96
+ },
97
+ };
98
+ if (conventional) {
99
+ payload.hookSpecificOutput.additionalContext = `Conventional commit hint: prefer "type: subject" (types: ${ALLOWED_COMMIT_TYPES.join(', ')}).`;
96
100
  }
97
- output('ask', `Create this local commit${subject ? `: ${subject}` : ''}?`);
101
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
98
102
  return;
99
103
  }
100
104
 
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // PreToolUse — deny edits outside the current task scope or inside a path
3
3
  // leased by a sibling worktree.
4
+ //
5
+ // F-145 loosening: removed the per-edit `git worktree list --porcelain`
6
+ // fork and the `requireTask` gate. The hook is now a single in-memory
7
+ // ledger lookup. Edits inside the project are allowed by default; only
8
+ // an active lease held by another agent on the same path denies.
4
9
 
5
10
  import { existsSync } from 'node:fs';
6
11
  import { resolve } from 'node:path';
@@ -36,20 +41,11 @@ process.stdin.on('end', () => {
36
41
  const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
37
42
  cwd,
38
43
  encoding: 'utf8',
39
- });
40
- const worktrees = spawnSync('git', ['worktree', 'list', '--porcelain'], {
41
- cwd,
42
- encoding: 'utf8',
44
+ timeout: 3_000,
43
45
  });
44
46
  const repoRoot = top.status === 0 && top.stdout.trim()
45
47
  ? resolve(top.stdout.trim())
46
48
  : cwd;
47
- const mainWorktree = worktrees.status === 0
48
- ? /^worktree (.+)$/m.exec(worktrees.stdout)?.[1]
49
- : null;
50
- const requireTask = Boolean(
51
- mainWorktree && resolve(mainWorktree) !== repoRoot,
52
- );
53
49
 
54
50
  let ledger;
55
51
  try {
@@ -58,7 +54,7 @@ process.stdin.on('end', () => {
58
54
  cwd,
59
55
  filePath,
60
56
  repoRoot,
61
- requireTask,
57
+ requireTask: false,
62
58
  });
63
59
  if (!authorization.allowed) {
64
60
  process.stdout.write(JSON.stringify({
@@ -27,12 +27,13 @@
27
27
  // }}
28
28
  //
29
29
  // Behaviour:
30
- // 1. Block writes to .env, .envrc, secrets/, credentials/, node_modules/.
31
- // .env.example/.sample/.template are explicitly allowed (docs, not
32
- // secrets). Lockfiles are allowed — they're package-manager output.
33
- // 2. Always return a small context line for the next AI decision.
34
- // (console.log / debugger / .only() are enforced by `make clean-check`
35
- // at commit time duplicate-warning here adds noise without safety.)
30
+ // Block writes to .env, .envrc, secrets/, credentials/, node_modules/.
31
+ // .env.example/.sample/.template are explicitly allowed (docs, not
32
+ // secrets). Lockfiles are allowed — they're package-manager output.
33
+ //
34
+ // F-145: removed the always-on `additionalContext` line. Every Write/
35
+ // Edit was emitting a tool/path note into the model's context — pure
36
+ // noise. The model already knows what tool and path it called.
36
37
 
37
38
  'use strict';
38
39
 
@@ -47,25 +48,21 @@ process.stdin.on('end', () => {
47
48
  const toolInput =
48
49
  (input.tool_input && typeof input.tool_input === 'object') ? input.tool_input : {};
49
50
 
50
- // Extract the "file_path" mapped onto Claude Code's Write/Edit/MultiEdit
51
- // shapes. Content scanning was removed (debug artifacts are enforced at
52
- // commit time via `make clean-check`, not write time).
53
51
  let filePath = '';
54
- if (toolName === 'Write') {
55
- filePath = String(toolInput.file_path || '');
56
- } else if (toolName === 'Edit') {
57
- filePath = String(toolInput.file_path || '');
58
- } else if (toolName === 'MultiEdit') {
52
+ if (/^(Write|Edit|MultiEdit)$/.test(toolName)) {
59
53
  filePath = String(toolInput.file_path || '');
60
54
  } else {
61
55
  process.stdout.write('{}\n');
62
56
  return;
63
57
  }
64
58
 
59
+ if (!filePath) {
60
+ process.stdout.write('{}\n');
61
+ return;
62
+ }
63
+
65
64
  const lowerPath = filePath.toLowerCase();
66
65
 
67
- // 1. Hard block — secrets + protected paths. .env.example/.sample/.template
68
- // and all lockfiles are explicitly allowed (docs / package-manager output).
69
66
  const allowed = [
70
67
  /\/\.env\.(example|sample|template|dist)$/i,
71
68
  /\/(package-lock|yarn|pnpm-lock|bun)\.lock\w*$/i,
@@ -80,38 +77,19 @@ process.stdin.on('end', () => {
80
77
  /\/node_modules\//,
81
78
  ];
82
79
 
83
- const isAllowed = filePath && allowed.some((re) => re.test(lowerPath));
84
- let blockReason = '';
85
- if (filePath && !isAllowed && blocked.some((re) => re.test(lowerPath))) {
86
- blockReason = filePath;
87
- }
88
-
89
- if (blockReason) {
80
+ const isAllowed = allowed.some((re) => re.test(lowerPath));
81
+ if (!isAllowed && blocked.some((re) => re.test(lowerPath))) {
90
82
  const out = {
91
83
  hookSpecificOutput: {
92
84
  hookEventName: 'PreToolUse',
93
85
  permissionDecision: 'deny',
94
86
  permissionDecisionReason:
95
- `Bizar PreToolUse: refusing to write to protected path '${blockReason}' (Bizar harness policy).`,
87
+ `Bizar PreToolUse: refusing to write to protected path '${filePath}' (Bizar harness policy).`,
96
88
  },
97
89
  };
98
90
  process.stdout.write(JSON.stringify(out) + '\n');
99
91
  return;
100
92
  }
101
93
 
102
- // 2. Always add a small context line for the AI's next decision.
103
- // Debug-artifact warnings (console.log / debugger / .only()) live in
104
- // `make clean-check` — duplicating them here would add noise without
105
- // adding safety (they're caught at commit time, not write time).
106
- const notes = [
107
- `Bizar PreToolUse: tool=${toolName || 'unknown'} path=${filePath || '(no path)'}`,
108
- ];
109
-
110
- const out = {
111
- hookSpecificOutput: {
112
- hookEventName: 'PreToolUse',
113
- additionalContext: notes.join(' '),
114
- },
115
- };
116
- process.stdout.write(JSON.stringify(out) + '\n');
94
+ process.stdout.write('{}\n');
117
95
  });
@@ -17,7 +17,8 @@ import { join } from 'node:path';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { findGitCommand } from './git-command-parser.mjs';
19
19
 
20
- const FRESHNESS_WINDOW_MS = 30 * 60 * 1000;
20
+ const FRESHNESS_WINDOW_MS = 4 * 60 * 60 * 1000;
21
+ const TRIVIAL_PATH = /^(?:CHANGELOG\.md|package(-lock)?\.json|.*\/package(-lock)?\.json|\.claude-plugin\/plugin\.json|packages\/[^/]+\/src\/version\.ts)$/;
21
22
 
22
23
  function marker(cwd) {
23
24
  const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], {
@@ -50,6 +51,18 @@ function readMarker(path) {
50
51
  }
51
52
  }
52
53
 
54
+ function isTrivialDiff(cwd) {
55
+ const result = spawnSync('git', ['diff', '--cached', '--name-only'], {
56
+ cwd,
57
+ encoding: 'utf8',
58
+ timeout: 5_000,
59
+ });
60
+ if (result.status !== 0) return false;
61
+ const paths = result.stdout.split('\n').map((p) => p.trim()).filter(Boolean);
62
+ if (paths.length === 0) return false;
63
+ return paths.every((p) => TRIVIAL_PATH.test(p));
64
+ }
65
+
53
66
  let raw = '';
54
67
  process.stdin.setEncoding('utf8');
55
68
  process.stdin.on('data', (chunk) => { raw += chunk; });
@@ -70,6 +83,9 @@ process.stdin.on('end', () => {
70
83
  const commandValue = input.tool_input?.command;
71
84
  const command = Array.isArray(commandValue) ? commandValue.join(' ') : String(commandValue || '');
72
85
  if (!findGitCommand(command, 'commit')) return;
86
+
87
+ if (isTrivialDiff(cwd)) return;
88
+
73
89
  const approval = readMarker(mark);
74
90
  const age = approval ? Date.now() - approval.timestamp : Infinity;
75
91
  const fingerprint = stagedFingerprint(cwd);
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "bizar-harness",
4
4
  "displayName": "Bizar Harness",
5
- "version": "10.12.0",
5
+ "version": "10.12.2",
6
6
  "description": "Guarded multi-agent workflows for Claude Code with a single orchestrator, durable workflow state, and explicit human approval boundaries.",
7
7
  "author": {
8
8
  "name": "Polderlabs"
@@ -16,8 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
16
16
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
17
17
  const HOOK_ROOT = resolve(PACKAGE_ROOT, '.claude', 'hooks');
18
18
  const PRETOOL_SAFETY_LEAVES = new Set([
19
- 'pretooluse-editwrite', 'path-ownership-guard', 'content-style-guard',
20
- 'pretooluse-bash', 'git-workflow-guard', 'simplify-guard', 'agent-model-guard',
19
+ 'pretooluse-editwrite', 'path-ownership-guard',
20
+ 'pretooluse-bash', 'git-workflow-guard',
21
+ 'agent-model-guard',
21
22
  ]);
22
23
 
23
24
  export const HOOK_PROGRAMS = Object.freeze({
@@ -65,16 +66,13 @@ export const EVENT_CHAINS = Object.freeze({
65
66
  'pre-tool-use': Object.freeze([
66
67
  'pretooluse-editwrite',
67
68
  'path-ownership-guard',
68
- 'content-style-guard',
69
69
  'pretooluse-bash',
70
70
  'git-workflow-guard',
71
- 'simplify-guard',
72
71
  ]),
73
72
  'permission-request': Object.freeze(['permission-request-policy']),
74
73
  'post-tool-use': Object.freeze([
75
74
  'posttooluse-editwrite',
76
75
  'auto-instinct',
77
- 'simplify-guard',
78
76
  ]),
79
77
  'post-tool-use-failure': Object.freeze(['post-tool-use-failure-policy']),
80
78
  'subagent-start': Object.freeze([
@@ -224,10 +222,10 @@ export function selectEventChain(eventKey, input = '') {
224
222
 
225
223
  if (eventKey === 'pre-tool-use') {
226
224
  if (/^(Write|Edit|MultiEdit)$/.test(toolName)) {
227
- return ['pretooluse-editwrite', 'path-ownership-guard', 'content-style-guard'];
225
+ return ['pretooluse-editwrite', 'path-ownership-guard'];
228
226
  }
229
227
  if (toolName === 'Bash') {
230
- return ['pretooluse-bash', 'git-workflow-guard', 'content-style-guard', 'simplify-guard'];
228
+ return ['pretooluse-bash', 'git-workflow-guard'];
231
229
  }
232
230
  if (toolName === 'Agent') return ['agent-model-guard'];
233
231
  return [];
@@ -236,7 +234,6 @@ export function selectEventChain(eventKey, input = '') {
236
234
  if (eventKey === 'post-tool-use') {
237
235
  if (/^(Write|Edit|MultiEdit)$/.test(toolName)) return ['posttooluse-editwrite'];
238
236
  if (toolName === 'Bash') return ['auto-instinct'];
239
- if (toolName === 'Skill') return ['simplify-guard'];
240
237
  return [];
241
238
  }
242
239
 
@@ -8,6 +8,7 @@ import chalk from 'chalk';
8
8
  import { runInstaller } from '../install.mjs';
9
9
  import { runUpdate } from '../update.mjs';
10
10
  import { runRepair } from '../repair.mjs';
11
+ import { parseFlags } from '../provision.mjs';
11
12
 
12
13
  // ── Help texts ──────────────────────────────────────────────────────────────────
13
14
 
@@ -18,7 +19,10 @@ export function showInstallHelp() {
18
19
  Usage:
19
20
  bizar install Install (or refresh) every component
20
21
  bizar install --dry-run Print what would happen, change nothing
21
- bizar install --force Overwrite existing files
22
+ bizar install --force Overwrite existing files AND prune stale
23
+ entries in ~/.claude/{agents,skills,
24
+ commands,rules,hooks}
25
+ bizar install --yes Assume yes for any non-destructive prompt
22
26
  bizar install --help Show this help
23
27
 
24
28
  Description:
@@ -89,7 +93,11 @@ export async function install(args, isHelpRequest) {
89
93
  showInstallHelp();
90
94
  return;
91
95
  }
92
- await runInstaller({});
96
+ // parseFlags lives in cli/provision.mjs and is the canonical argv
97
+ // parser for the installer family. Reusing it keeps install and
98
+ // update in lockstep on flag semantics.
99
+ const { mode, dryRun, force, yes } = parseFlags(args);
100
+ await runInstaller({ mode, dryRun, force, yes });
93
101
  // v4.4.3 — After install, repair any stale bin symlinks so the
94
102
  // user picks up the new code.
95
103
  try {
@@ -13,12 +13,13 @@ import { printInstallLocations } from './paths.mjs';
13
13
  * Thin orchestrator entry point.
14
14
  * @param {object} opts
15
15
  * @param {boolean} [opts.dryRun]
16
- * @param {boolean} [opts.force]
17
- * @param {boolean} [opts.quiet] - Only print the location card
18
- * @param {string} [opts.mode] - 'install' | 'update'
16
+ * @param {boolean} [opts.force] - overwrite existing files AND prune stale entries
17
+ * @param {boolean} [opts.quiet] - Only print the location card
18
+ * @param {string} [opts.mode] - 'install' | 'update'
19
+ * @param {boolean} [opts.yes] - assume yes for any non-destructive prompts
19
20
  */
20
21
  export async function runInstaller(opts = {}) {
21
- const { dryRun = false, force = false, quiet = false, mode = 'install' } = opts;
22
+ const { dryRun = false, force = false, quiet = false, mode = 'install', yes = false } = opts;
22
23
 
23
24
  if (quiet) {
24
25
  printInstallLocations({ dryRun, force });
@@ -28,5 +29,5 @@ export async function runInstaller(opts = {}) {
28
29
  showBanner();
29
30
  printInstallLocations({ dryRun, force });
30
31
 
31
- return runProvision({ mode, dryRun, force });
32
+ return runProvision({ mode, dryRun, force, yes });
32
33
  }
package/cli/provision.mjs CHANGED
@@ -352,6 +352,69 @@ function syncDir(srcDir, destDir, opts = {}) {
352
352
  return { copied, skipped };
353
353
  }
354
354
 
355
+ /**
356
+ * Remove entries in destDir that are not present in srcDir.
357
+ * Honors the same recursive shape and filter as syncDir().
358
+ * Used by --force installs to clear stale files left over from
359
+ * previous versions (e.g. agents renamed by F-112).
360
+ *
361
+ * Policy: `bizar install --force` treats the global state under
362
+ * `~/.claude/{agents,skills,commands,rules,hooks}` as fully Bizar-
363
+ * managed. Anything in dest that does not appear in src is removed.
364
+ * This is intentionally aggressive; users who keep hand-edited
365
+ * entries under those paths should run without `--force` to leave
366
+ * them alone. See PROGRESS.md F-141 for the trade-off.
367
+ *
368
+ * Returns { removed, kept } counts.
369
+ */
370
+ export function pruneStale(srcDir, destDir, opts = {}) {
371
+ if (!existsSync(destDir)) return { removed: 0, kept: 0 };
372
+ if (!existsSync(srcDir)) return { removed: 0, kept: 0 };
373
+ let removed = 0, kept = 0;
374
+ for (const name of readdirSync(destDir)) {
375
+ if (name.startsWith('.')) continue;
376
+ const dp = join(destDir, name);
377
+ const sp = join(srcDir, name);
378
+ let dstStat;
379
+ try { dstStat = statSync(dp); } catch { continue; }
380
+ const srcExists = existsSync(sp);
381
+ if (dstStat.isDirectory()) {
382
+ if (!srcExists) {
383
+ rmSync(dp, { recursive: true, force: true });
384
+ removed++;
385
+ continue;
386
+ }
387
+ const r = pruneStale(sp, dp, opts);
388
+ removed += r.removed;
389
+ kept += r.kept;
390
+ // Drop now-empty directories.
391
+ try {
392
+ if (readdirSync(dp).length === 0) rmSync(dp, { recursive: true, force: true });
393
+ } catch { /* ignore */ }
394
+ continue;
395
+ }
396
+ if (!srcExists) {
397
+ if (opts.filter && !opts.filter(name, dp)) { kept++; continue; }
398
+ rmSync(dp, { force: true });
399
+ removed++;
400
+ } else {
401
+ kept++;
402
+ }
403
+ }
404
+ return { removed, kept };
405
+ }
406
+
407
+ /**
408
+ * Run the prune pass under the same conditions used by every sync*
409
+ * helper and return a `{ pruned, tail }` pair the caller appends
410
+ * to its result message. No-op when `force` is false.
411
+ */
412
+ function pruneReport(srcDir, destDir, filter, force) {
413
+ if (!force) return { pruned: 0, tail: '' };
414
+ const pruned = pruneStale(srcDir, destDir, { filter }).removed;
415
+ return { pruned, tail: pruned ? `, pruned ${pruned} stale` : '' };
416
+ }
417
+
355
418
  // F-107: syncAgentFiles removed. Agent definitions live at
356
419
  // .claude/agents/ (Claude Code canonical) — they were never sourced
357
420
  // from config/agents/ in Claude Code era, and the legacy source dir
@@ -366,10 +429,11 @@ export async function syncAgentFiles({ dryRun = false, force = false } = {}) {
366
429
  const src = join(REPO_ROOT, '.claude', 'agents');
367
430
  const dest = CLAUDE_AGENTS_DIR;
368
431
  if (!existsSync(src)) return { ok: true, message: `no agents source at ${src}`, copied: 0, skipped: 0 };
369
- if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}` };
432
+ if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}${force ? ' (prune stale)' : ''}` };
370
433
  ensureDir(dest);
371
434
  const { copied, skipped } = syncDir(src, dest, { filter: n => n.endsWith('.md') });
372
- return { ok: true, message: `${copied} agent(s) synced (${skipped} kept)`, copied, skipped };
435
+ const { pruned, tail } = pruneReport(src, dest, n => n.endsWith('.md'), force);
436
+ return { ok: true, message: `${copied} agent(s) synced (${skipped} kept)${tail}`, copied, skipped, pruned };
373
437
  }
374
438
 
375
439
  // F-113: syncModelRouter added. The model-router.json file lives in
@@ -399,11 +463,11 @@ export async function syncModelRouter({ dryRun = false, force = false } = {}) {
399
463
  return { ok: true, message: `model-router.json → ${dest}`, path: dest };
400
464
  }
401
465
 
402
- export async function syncSkillFiles({ dryRun = false } = {}) {
466
+ export async function syncSkillFiles({ dryRun = false, force = false } = {}) {
403
467
  const src = join(REPO_ROOT, 'config', 'skills');
404
468
  const dest = CLAUDE_SKILLS_DIR;
405
469
  if (!existsSync(src)) return { ok: true, message: `no skills source at ${src}`, copied: 0, skipped: 0 };
406
- if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}` };
470
+ if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}${force ? ' (prune stale)' : ''}` };
407
471
  ensureDir(dest); copyDirContents(src, dest);
408
472
  const sharedBaseline = join(REPO_ROOT, 'config', 'agents', '_shared', 'AGENT_BASELINE.md');
409
473
  if (existsSync(sharedBaseline)) {
@@ -411,39 +475,50 @@ export async function syncSkillFiles({ dryRun = false } = {}) {
411
475
  ensureDir(baselineDir);
412
476
  copyFileSync(sharedBaseline, join(baselineDir, 'SKILL.md'));
413
477
  }
478
+ // Skill packs are directories containing SKILL.md; a stray non-SKILL.md
479
+ // file in dest is almost certainly user-owned. Restrict the prune pass
480
+ // to .md files only.
481
+ const { pruned, tail } = pruneReport(src, dest, n => n.endsWith('.md'), force);
414
482
  const count = readdirSync(dest).filter(n => { try { return statSync(join(dest, n)).isDirectory(); } catch { return false; } }).length;
415
- return { ok: true, message: `${count} skill(s) synced`, copied: count, skipped: 0 };
483
+ return { ok: true, message: `${count} skill(s) synced${tail}`, copied: count, skipped: 0, pruned };
416
484
  }
417
485
 
418
- export async function syncCommandFiles({ dryRun = false } = {}) {
486
+ export async function syncCommandFiles({ dryRun = false, force = false } = {}) {
419
487
  const candidates = [join(REPO_ROOT, '.claude', 'commands'), join(REPO_ROOT, 'config', 'commands')];
420
488
  let src = null;
421
489
  for (const c of candidates) if (existsSync(c)) { src = c; break; }
422
490
  if (!src) return { ok: true, message: 'no commands source found', copied: 0, skipped: 0 };
423
491
  const dest = CLAUDE_COMMANDS_DIR;
424
- if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}` };
492
+ if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}${force ? ' (prune stale)' : ''}` };
425
493
  ensureDir(dest);
426
494
  const { copied, skipped } = syncDir(src, dest, { filter: n => n.endsWith('.md') });
427
- return { ok: true, message: `${copied} command(s) synced (${skipped} kept)`, copied, skipped };
495
+ const { pruned, tail } = pruneReport(src, dest, n => n.endsWith('.md'), force);
496
+ return { ok: true, message: `${copied} command(s) synced (${skipped} kept)${tail}`, copied, skipped, pruned };
428
497
  }
429
498
 
430
- export async function syncRulesFiles({ dryRun = false } = {}) {
499
+ export async function syncRulesFiles({ dryRun = false, force = false } = {}) {
431
500
  const src = join(REPO_ROOT, 'config', 'rules');
432
501
  const dest = CLAUDE_RULES_DIR;
433
502
  if (!existsSync(src)) return { ok: true, message: `no rules source at ${src}`, copied: 0, skipped: 0 };
434
- if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}` };
503
+ if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}${force ? ' (prune stale)' : ''}` };
435
504
  ensureDir(dest);
436
- const { copied, skipped } = syncDir(src, dest, { filter: n => n.endsWith('.md') || n.endsWith('.txt') });
437
- return { ok: true, message: `${copied} rule(s) synced (${skipped} kept)`, copied, skipped };
505
+ const ruleFilter = n => n.endsWith('.md') || n.endsWith('.txt');
506
+ const { copied, skipped } = syncDir(src, dest, { filter: ruleFilter });
507
+ const { pruned, tail } = pruneReport(src, dest, ruleFilter, force);
508
+ return { ok: true, message: `${copied} rule(s) synced (${skipped} kept)${tail}`, copied, skipped, pruned };
438
509
  }
439
510
 
440
- export async function syncHookFiles({ dryRun = false } = {}) {
511
+ export async function syncHookFiles({ dryRun = false, force = false } = {}) {
441
512
  const src = join(REPO_ROOT, '.claude', 'hooks');
442
513
  const dest = CLAUDE_HOOKS_DIR;
443
514
  ensureDir(dest);
444
515
  if (!existsSync(src)) return { ok: true, message: `no hooks source at ${src}`, copied: 0, skipped: 0 };
445
- if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}` };
516
+ if (dryRun) return { ok: true, message: `[dry-run] would sync ${src} → ${dest}${force ? ' (prune stale)' : ''}` };
446
517
  copyDirContents(src, dest);
518
+ // Hooks are .mjs / .sh scripts. Restrict prune to those extensions so
519
+ // any user-owned file (READMEs, fixtures, etc.) is not removed.
520
+ const hookFilter = n => n.endsWith('.mjs') || n.endsWith('.sh');
521
+ const { pruned, tail } = pruneReport(src, dest, hookFilter, force);
447
522
  for (const name of readdirSync(dest)) {
448
523
  const fp = join(dest, name);
449
524
  try {
@@ -451,7 +526,7 @@ export async function syncHookFiles({ dryRun = false } = {}) {
451
526
  if (st.isFile() && (name.endsWith('.sh') || name.endsWith('.mjs'))) chmodSync(fp, 0o755);
452
527
  } catch { /* ignore */ }
453
528
  }
454
- return { ok: true, message: 'hook scripts installed', copied: readdirSync(dest).length, skipped: 0 };
529
+ return { ok: true, message: `hook scripts installed${tail}`, copied: readdirSync(dest).length, skipped: 0, pruned };
455
530
  }
456
531
 
457
532
  // ─── git hooks ──────────────────────────────────────────────────────────────
@@ -992,7 +1067,7 @@ export async function syncConfigExtras({ dryRun = false } = {}) {
992
1067
 
993
1068
  // ─── CLI entry ──────────────────────────────────────────────────────────────
994
1069
 
995
- function parseFlags(argv) {
1070
+ export function parseFlags(argv) {
996
1071
  const opts = { mode: 'install', dryRun: false, force: false, yes: false, start: true, update: false };
997
1072
  for (let i = 0; i < argv.length; i++) {
998
1073
  const a = argv[i];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.12.0",
3
+ "version": "10.12.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": {
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export declare const SDK_VERSION: "10.12.0";
4
+ export declare const SDK_VERSION: "10.12.2";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export const SDK_VERSION = "10.12.0";
4
+ export const SDK_VERSION = "10.12.2";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.12.0",
3
+ "version": "10.12.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",