@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.
- package/.claude/hooks/git-workflow-guard.mjs +14 -10
- package/.claude/hooks/path-ownership-guard.mjs +7 -11
- package/.claude/hooks/pretooluse-editwrite.mjs +17 -39
- package/.claude/hooks/simplify-guard.mjs +17 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli/commands/hook.mjs +5 -8
- package/cli/commands/install.mjs +10 -2
- package/cli/install/index.mjs +6 -5
- package/cli/provision.mjs +91 -16
- package/package.json +1 -1
- package/packages/sdk/dist/version.d.ts +1 -1
- package/packages/sdk/dist/version.js +1 -1
- package/packages/sdk/package.json +1 -1
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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 (
|
|
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 =
|
|
84
|
-
|
|
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 '${
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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"
|
package/cli/commands/hook.mjs
CHANGED
|
@@ -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',
|
|
20
|
-
'pretooluse-bash', 'git-workflow-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'
|
|
225
|
+
return ['pretooluse-editwrite', 'path-ownership-guard'];
|
|
228
226
|
}
|
|
229
227
|
if (toolName === 'Bash') {
|
|
230
|
-
return ['pretooluse-bash', 'git-workflow-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
|
|
package/cli/commands/install.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/cli/install/index.mjs
CHANGED
|
@@ -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]
|
|
18
|
-
* @param {string} [opts.mode]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
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:
|
|
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