@soleri/cli 9.4.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/commands/hooks.js +126 -0
  2. package/dist/commands/hooks.js.map +1 -1
  3. package/dist/commands/install.js +5 -0
  4. package/dist/commands/install.js.map +1 -1
  5. package/dist/hook-packs/converter/README.md +99 -0
  6. package/dist/hook-packs/converter/template.d.ts +36 -0
  7. package/dist/hook-packs/converter/template.js +127 -0
  8. package/dist/hook-packs/converter/template.js.map +1 -0
  9. package/dist/hook-packs/converter/template.test.ts +133 -0
  10. package/dist/hook-packs/converter/template.ts +163 -0
  11. package/dist/hook-packs/flock-guard/README.md +65 -0
  12. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  13. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  14. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  15. package/dist/hook-packs/full/manifest.json +8 -1
  16. package/dist/hook-packs/graduation.d.ts +11 -0
  17. package/dist/hook-packs/graduation.js +48 -0
  18. package/dist/hook-packs/graduation.js.map +1 -0
  19. package/dist/hook-packs/graduation.ts +65 -0
  20. package/dist/hook-packs/installer.js +3 -1
  21. package/dist/hook-packs/installer.js.map +1 -1
  22. package/dist/hook-packs/installer.ts +3 -1
  23. package/dist/hook-packs/marketing-research/README.md +37 -0
  24. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  25. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  26. package/dist/hook-packs/registry.d.ts +1 -0
  27. package/dist/hook-packs/registry.js +14 -4
  28. package/dist/hook-packs/registry.js.map +1 -1
  29. package/dist/hook-packs/registry.ts +18 -4
  30. package/dist/hook-packs/safety/README.md +50 -0
  31. package/dist/hook-packs/safety/manifest.json +23 -0
  32. package/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
  33. package/dist/hook-packs/validator.d.ts +32 -0
  34. package/dist/hook-packs/validator.js +126 -0
  35. package/dist/hook-packs/validator.js.map +1 -0
  36. package/dist/hook-packs/validator.ts +158 -0
  37. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  38. package/package.json +1 -1
  39. package/src/__tests__/flock-guard.test.ts +225 -0
  40. package/src/__tests__/graduation.test.ts +199 -0
  41. package/src/__tests__/hook-packs.test.ts +44 -19
  42. package/src/__tests__/hooks-convert.test.ts +342 -0
  43. package/src/__tests__/validator.test.ts +265 -0
  44. package/src/commands/hooks.ts +172 -0
  45. package/src/commands/install.ts +6 -0
  46. package/src/hook-packs/converter/README.md +99 -0
  47. package/src/hook-packs/converter/template.test.ts +133 -0
  48. package/src/hook-packs/converter/template.ts +163 -0
  49. package/src/hook-packs/flock-guard/README.md +65 -0
  50. package/src/hook-packs/flock-guard/manifest.json +36 -0
  51. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  52. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  53. package/src/hook-packs/full/manifest.json +8 -1
  54. package/src/hook-packs/graduation.ts +65 -0
  55. package/src/hook-packs/installer.ts +3 -1
  56. package/src/hook-packs/marketing-research/README.md +37 -0
  57. package/src/hook-packs/marketing-research/manifest.json +24 -0
  58. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  59. package/src/hook-packs/registry.ts +18 -4
  60. package/src/hook-packs/safety/README.md +50 -0
  61. package/src/hook-packs/safety/manifest.json +23 -0
  62. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  63. package/src/hook-packs/validator.ts +158 -0
  64. package/src/hook-packs/yolo-safety/manifest.json +3 -19
@@ -1,9 +1,24 @@
1
1
  import type { Command } from 'commander';
2
+ import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { SUPPORTED_EDITORS, type EditorId } from '../hooks/templates.js';
3
5
  import { installHooks, removeHooks, detectInstalledHooks } from '../hooks/generator.js';
4
6
  import { detectAgent } from '../utils/agent-context.js';
5
7
  import { listPacks, getPack } from '../hook-packs/registry.js';
6
8
  import { installPack, removePack, isPackInstalled } from '../hook-packs/installer.js';
9
+ import { promotePack, demotePack } from '../hook-packs/graduation.js';
10
+ import {
11
+ generateHookScript,
12
+ generateManifest,
13
+ HOOK_EVENTS,
14
+ ACTION_LEVELS,
15
+ } from '../hook-packs/converter/template.js';
16
+ import type {
17
+ HookEvent,
18
+ ActionLevel,
19
+ HookConversionConfig,
20
+ } from '../hook-packs/converter/template.js';
21
+ import { generateFixtures, validateHookScript } from '../hook-packs/validator.js';
7
22
  import * as log from '../utils/logger.js';
8
23
 
9
24
  export function registerHooks(program: Command): void {
@@ -201,6 +216,163 @@ export function registerHooks(program: Command): void {
201
216
  const total = installed.length + scripts.length + lifecycleHooks.length;
202
217
  log.info(`Pack "${packName}" upgraded to v${packVersion} (${total} items)`);
203
218
  });
219
+
220
+ hooks
221
+ .command('convert')
222
+ .argument('<name>', 'Name for the converted hook pack (kebab-case)')
223
+ .requiredOption(
224
+ '--event <event>',
225
+ 'Hook event: PreToolUse, PostToolUse, PreCompact, Notification, Stop',
226
+ )
227
+ .option(
228
+ '--matcher <tools>',
229
+ 'Tool name matcher (e.g., "Write|Edit") — for PreToolUse/PostToolUse',
230
+ )
231
+ .option('--pattern <globs...>', 'File glob patterns to match (e.g., "**/marketing/**")')
232
+ .option('--action <level>', 'Action level: remind (default), warn, block', 'remind')
233
+ .requiredOption('--message <text>', 'Context message when hook fires')
234
+ .option('--project', 'Output to .soleri/hook-packs/ instead of built-in packs dir')
235
+ .description('Convert a skill into an automated hook pack')
236
+ .action(
237
+ (
238
+ name: string,
239
+ opts: {
240
+ event: string;
241
+ matcher?: string;
242
+ pattern?: string[];
243
+ action: string;
244
+ message: string;
245
+ project?: boolean;
246
+ },
247
+ ) => {
248
+ if (!HOOK_EVENTS.includes(opts.event as HookEvent)) {
249
+ log.fail(`Invalid event "${opts.event}". Must be one of: ${HOOK_EVENTS.join(', ')}`);
250
+ process.exit(1);
251
+ }
252
+
253
+ if (!ACTION_LEVELS.includes(opts.action as ActionLevel)) {
254
+ log.fail(`Invalid action "${opts.action}". Must be one of: ${ACTION_LEVELS.join(', ')}`);
255
+ process.exit(1);
256
+ }
257
+
258
+ const config: HookConversionConfig = {
259
+ name,
260
+ event: opts.event as HookEvent,
261
+ toolMatcher: opts.matcher,
262
+ filePatterns: opts.pattern,
263
+ action: opts.action as ActionLevel,
264
+ message: opts.message,
265
+ };
266
+
267
+ const script = generateHookScript(config);
268
+ const manifest = generateManifest(config);
269
+
270
+ const baseDir = opts.project
271
+ ? join(process.cwd(), '.soleri', 'hook-packs', name)
272
+ : join(process.cwd(), 'packages', 'cli', 'src', 'hook-packs', name);
273
+
274
+ const scriptsDir = join(baseDir, 'scripts');
275
+ mkdirSync(scriptsDir, { recursive: true });
276
+
277
+ const manifestPath = join(baseDir, 'manifest.json');
278
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
279
+ log.pass(`Created ${manifestPath}`);
280
+
281
+ const scriptPath = join(scriptsDir, `${name}.sh`);
282
+ writeFileSync(scriptPath, script);
283
+ if (process.platform !== 'win32') {
284
+ chmodSync(scriptPath, 0o755);
285
+ }
286
+ log.pass(`Created ${scriptPath}`);
287
+
288
+ log.info(`Hook pack "${name}" generated (event: ${opts.event}, action: ${opts.action})`);
289
+ },
290
+ );
291
+ hooks
292
+ .command('promote')
293
+ .argument('<pack>', 'Hook pack name')
294
+ .description('Promote hook action level: remind → warn → block')
295
+ .action((packName: string) => {
296
+ try {
297
+ const result = promotePack(packName);
298
+ log.pass(`${packName}: ${result.previousLevel} → ${result.newLevel}`);
299
+ } catch (err: unknown) {
300
+ log.fail((err as Error).message);
301
+ process.exit(1);
302
+ }
303
+ });
304
+
305
+ hooks
306
+ .command('demote')
307
+ .argument('<pack>', 'Hook pack name')
308
+ .description('Demote hook action level: block → warn → remind')
309
+ .action((packName: string) => {
310
+ try {
311
+ const result = demotePack(packName);
312
+ log.pass(`${packName}: ${result.previousLevel} → ${result.newLevel}`);
313
+ } catch (err: unknown) {
314
+ log.fail((err as Error).message);
315
+ process.exit(1);
316
+ }
317
+ });
318
+
319
+ hooks
320
+ .command('test')
321
+ .argument('<pack>', 'Hook pack name to test')
322
+ .description('Run validation tests against a hook pack')
323
+ .action((packName: string) => {
324
+ const pack = getPack(packName);
325
+ if (!pack) {
326
+ log.fail(`Unknown pack "${packName}"`);
327
+ process.exit(1);
328
+ }
329
+
330
+ // Find the script
331
+ const scripts = pack.manifest.scripts;
332
+ if (!scripts || scripts.length === 0) {
333
+ log.fail(`Pack "${packName}" has no scripts to test`);
334
+ process.exit(1);
335
+ }
336
+
337
+ const scriptFile = scripts[0].file;
338
+ // Resolve script path from pack directory
339
+ const scriptPath = join(pack.dir, 'scripts', scriptFile);
340
+
341
+ if (!existsSync(scriptPath)) {
342
+ log.fail(`Script not found: ${scriptPath}`);
343
+ process.exit(1);
344
+ }
345
+
346
+ // Determine event and matcher from lifecycle hooks
347
+ const lc = pack.manifest.lifecycleHooks?.[0];
348
+ const event = (lc?.event ?? 'PreToolUse') as HookEvent;
349
+ const toolMatcher = lc?.matcher;
350
+
351
+ // Generate fixtures and run
352
+ const fixtures = generateFixtures(event, toolMatcher);
353
+ log.heading(`Testing ${packName} (${fixtures.length} fixtures)`);
354
+
355
+ const report = validateHookScript(scriptPath, fixtures);
356
+ log.info(`Results: ${report.passed}/${report.total} passed`);
357
+
358
+ if (report.falsePositives.length > 0) {
359
+ log.fail(`False positives: ${report.falsePositives.length}`);
360
+ for (const fp of report.falsePositives) {
361
+ log.warn(` ${fp.fixture.name}: expected no match, got output`);
362
+ }
363
+ }
364
+
365
+ if (report.falseNegatives.length > 0) {
366
+ log.warn(`False negatives: ${report.falseNegatives.length}`);
367
+ for (const fn of report.falseNegatives) {
368
+ log.warn(` ${fn.fixture.name}: expected match, got no output`);
369
+ }
370
+ }
371
+
372
+ if (report.falsePositives.length === 0 && report.falseNegatives.length === 0) {
373
+ log.pass('All fixtures passed — zero false positives');
374
+ }
375
+ });
204
376
  }
205
377
 
206
378
  function isValidEditor(editor: string): editor is EditorId {
@@ -170,6 +170,12 @@ function escapeRegExp(s: string): string {
170
170
  * e.g., typing `ernesto` opens Claude Code with that agent's MCP config.
171
171
  */
172
172
  function installLauncher(agentId: string, agentDir: string): void {
173
+ // Launcher scripts to /usr/local/bin are Unix-only
174
+ if (process.platform === 'win32') {
175
+ p.log.info('Launcher scripts are not supported on Windows — skipping');
176
+ return;
177
+ }
178
+
173
179
  const binPath = join('/usr/local/bin', agentId);
174
180
 
175
181
  const script = [
@@ -0,0 +1,99 @@
1
+ # Skill-to-Hook Conversion System
2
+
3
+ Convert repeatedly-invoked skills into automated Claude Code hooks. Hooks fire automatically on matching events — no manual invocation, no LLM round trip.
4
+
5
+ ## Workflow
6
+
7
+ ```
8
+ Score → Convert → Test → Graduate
9
+ ```
10
+
11
+ ### 1. Score the Candidate
12
+
13
+ Evaluate 4 dimensions (each HIGH or LOW):
14
+
15
+ | Dimension | HIGH when... |
16
+ | --------------------- | -------------------------------------------------------- |
17
+ | **Frequency** | 3+ manual calls per session for same event type |
18
+ | **Event Correlation** | Skill consistently triggers on a recognizable hook event |
19
+ | **Determinism** | Skill produces consistent, non-exploratory guidance |
20
+ | **Autonomy** | Skill requires no interactive user decisions |
21
+
22
+ **Threshold:** 3/4 HIGH = candidate for conversion.
23
+
24
+ ```typescript
25
+ import { scoreCandidateForConversion } from '@soleri/core';
26
+
27
+ const result = scoreCandidateForConversion({
28
+ frequency: 'HIGH',
29
+ eventCorrelation: 'HIGH',
30
+ determinism: 'HIGH',
31
+ autonomy: 'LOW',
32
+ });
33
+ // result.candidate === true (3/4)
34
+ ```
35
+
36
+ ### 2. Convert
37
+
38
+ ```bash
39
+ soleri hooks convert marketing-research \
40
+ --event PreToolUse \
41
+ --matcher "Write|Edit" \
42
+ --pattern "**/marketing/**" \
43
+ --action remind \
44
+ --message "Check brand guidelines and A/B testing data"
45
+ ```
46
+
47
+ This creates a hook pack with `manifest.json` and a POSIX shell script.
48
+
49
+ ### 3. Test
50
+
51
+ ```bash
52
+ soleri hooks test marketing-research
53
+ ```
54
+
55
+ Runs 15 fixtures (5 matching + 10 non-matching) against the hook script. Reports false positives and false negatives. **Zero false positives required before graduation.**
56
+
57
+ ### 4. Graduate
58
+
59
+ Hooks default to `remind` (gentle context injection). After proving zero false positives:
60
+
61
+ ```bash
62
+ soleri hooks promote marketing-research # remind → warn
63
+ soleri hooks promote marketing-research # warn → block
64
+ ```
65
+
66
+ To step back:
67
+
68
+ ```bash
69
+ soleri hooks demote marketing-research # block → warn
70
+ ```
71
+
72
+ ## Hook Events
73
+
74
+ | Event | When it fires |
75
+ | -------------- | -------------------------------------------- |
76
+ | `PreToolUse` | Before a tool call (Write, Edit, Bash, etc.) |
77
+ | `PostToolUse` | After a tool call completes |
78
+ | `PreCompact` | Before context compaction |
79
+ | `Notification` | On notification events |
80
+ | `Stop` | When the session ends |
81
+
82
+ ## Action Levels
83
+
84
+ | Level | Behavior |
85
+ | -------- | ------------------------------------- |
86
+ | `remind` | Inject context, don't block (default) |
87
+ | `warn` | Inject warning context, don't block |
88
+ | `block` | Block the operation with a reason |
89
+
90
+ ## CLI Commands
91
+
92
+ | Command | Description |
93
+ | --------------------------------- | ------------------------------------- |
94
+ | `soleri hooks convert <name>` | Create a new hook pack from a skill |
95
+ | `soleri hooks test <pack>` | Validate a hook pack against fixtures |
96
+ | `soleri hooks promote <pack>` | Step up action level |
97
+ | `soleri hooks demote <pack>` | Step down action level |
98
+ | `soleri hooks add-pack <pack>` | Install a hook pack |
99
+ | `soleri hooks remove-pack <pack>` | Uninstall a hook pack |
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateHookScript, generateManifest, HOOK_EVENTS, ACTION_LEVELS } from './template.js';
3
+ import type { HookConversionConfig } from './template.js';
4
+
5
+ describe('generateHookScript', () => {
6
+ const baseConfig: HookConversionConfig = {
7
+ name: 'test-hook',
8
+ event: 'PreToolUse',
9
+ toolMatcher: 'Write|Edit',
10
+ filePatterns: ['**/marketing/**'],
11
+ action: 'remind',
12
+ message: 'Check brand guidelines before editing marketing files',
13
+ };
14
+
15
+ it('should generate a valid POSIX shell script', () => {
16
+ const script = generateHookScript(baseConfig);
17
+ expect(script).toContain('#!/bin/sh');
18
+ expect(script).toContain('set -eu');
19
+ expect(script).toContain('INPUT=$(cat)');
20
+ });
21
+
22
+ it('should include tool matcher for PreToolUse', () => {
23
+ const script = generateHookScript(baseConfig);
24
+ expect(script).toContain('TOOL_NAME=');
25
+ expect(script).toContain('Write|Edit');
26
+ expect(script).toContain('case "$TOOL_NAME" in');
27
+ });
28
+
29
+ it('should include file pattern matching', () => {
30
+ const script = generateHookScript(baseConfig);
31
+ expect(script).toContain('FILE_PATH=');
32
+ expect(script).toContain('MATCHED=false');
33
+ expect(script).toContain('marketing');
34
+ });
35
+
36
+ it('should output remind action by default', () => {
37
+ const script = generateHookScript(baseConfig);
38
+ expect(script).toContain('REMINDER:');
39
+ expect(script).toContain('continue: true');
40
+ });
41
+
42
+ it('should output warn action', () => {
43
+ const script = generateHookScript({ ...baseConfig, action: 'warn' });
44
+ expect(script).toContain('WARNING:');
45
+ expect(script).toContain('continue: true');
46
+ });
47
+
48
+ it('should output block action', () => {
49
+ const script = generateHookScript({ ...baseConfig, action: 'block' });
50
+ expect(script).toContain('BLOCKED:');
51
+ expect(script).toContain('continue: false');
52
+ });
53
+
54
+ it('should skip tool matcher for non-tool events', () => {
55
+ const script = generateHookScript({ ...baseConfig, event: 'PreCompact' });
56
+ expect(script).not.toContain('TOOL_NAME');
57
+ expect(script).not.toContain('case');
58
+ });
59
+
60
+ it('should skip file pattern matching when no patterns', () => {
61
+ const script = generateHookScript({ ...baseConfig, filePatterns: undefined });
62
+ expect(script).not.toContain('FILE_PATH');
63
+ expect(script).not.toContain('MATCHED');
64
+ });
65
+
66
+ it('should generate scripts for all 5 hook events', () => {
67
+ for (const event of HOOK_EVENTS) {
68
+ const script = generateHookScript({ ...baseConfig, event });
69
+ expect(script).toContain(`# Event: ${event}`);
70
+ expect(script).toContain('#!/bin/sh');
71
+ }
72
+ });
73
+
74
+ it('should escape single quotes in messages', () => {
75
+ const script = generateHookScript({ ...baseConfig, message: "Don't forget the guidelines" });
76
+ // Should not have unbalanced quotes
77
+ expect(script).toContain('forget');
78
+ });
79
+ });
80
+
81
+ describe('generateManifest', () => {
82
+ const config: HookConversionConfig = {
83
+ name: 'my-hook',
84
+ event: 'PreToolUse',
85
+ toolMatcher: 'Write',
86
+ action: 'remind',
87
+ message: 'Test message',
88
+ };
89
+
90
+ it('should generate valid manifest with required fields', () => {
91
+ const manifest = generateManifest(config);
92
+ expect(manifest.name).toBe('my-hook');
93
+ expect(manifest.version).toBe('1.0.0');
94
+ expect(manifest.hooks).toEqual([]);
95
+ expect(manifest.scripts).toHaveLength(1);
96
+ expect(manifest.lifecycleHooks).toHaveLength(1);
97
+ });
98
+
99
+ it('should set script name and file correctly', () => {
100
+ const manifest = generateManifest(config);
101
+ expect(manifest.scripts![0].name).toBe('my-hook');
102
+ expect(manifest.scripts![0].file).toBe('my-hook.sh');
103
+ expect(manifest.scripts![0].targetDir).toBe('hooks');
104
+ });
105
+
106
+ it('should set lifecycle hook event and command', () => {
107
+ const manifest = generateManifest(config);
108
+ const lc = manifest.lifecycleHooks![0];
109
+ expect(lc.event).toBe('PreToolUse');
110
+ expect(lc.command).toBe('sh ~/.claude/hooks/my-hook.sh');
111
+ expect(lc.type).toBe('command');
112
+ expect(lc.timeout).toBe(10);
113
+ });
114
+
115
+ it('should use description from config or fallback to message', () => {
116
+ expect(generateManifest(config).description).toBe('Test message');
117
+ expect(generateManifest({ ...config, description: 'Custom desc' }).description).toBe(
118
+ 'Custom desc',
119
+ );
120
+ });
121
+
122
+ it('should include actionLevel', () => {
123
+ expect(generateManifest(config).actionLevel).toBe('remind');
124
+ expect(generateManifest({ ...config, action: 'block' }).actionLevel).toBe('block');
125
+ });
126
+
127
+ it('should generate manifests for all action levels', () => {
128
+ for (const action of ACTION_LEVELS) {
129
+ const manifest = generateManifest({ ...config, action });
130
+ expect(manifest.actionLevel).toBe(action);
131
+ }
132
+ });
133
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Conversion template for skill-to-hook conversion.
3
+ * Generates POSIX shell scripts and pack manifests for converted hooks.
4
+ */
5
+
6
+ import type { HookPackManifest, HookPackLifecycleHook, HookPackScript } from '../registry.js';
7
+
8
+ /** Supported Claude Code hook events */
9
+ export type HookEvent = 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'Notification' | 'Stop';
10
+
11
+ /** Action levels for graduated enforcement */
12
+ export type ActionLevel = 'remind' | 'warn' | 'block';
13
+
14
+ /** Configuration for generating a converted hook */
15
+ export interface HookConversionConfig {
16
+ /** Hook pack name (kebab-case) */
17
+ name: string;
18
+ /** Claude Code hook event to trigger on */
19
+ event: HookEvent;
20
+ /** Tool name matcher (e.g., 'Write|Edit', 'Bash') — only for PreToolUse/PostToolUse */
21
+ toolMatcher?: string;
22
+ // File glob patterns to match (e.g., ['**/marketing/**', '**/*.tsx'])
23
+ filePatterns?: string[];
24
+ /** Action level: remind (default), warn, or block */
25
+ action: ActionLevel;
26
+ /** Context message to inject when the hook fires */
27
+ message: string;
28
+ /** Optional description for the pack */
29
+ description?: string;
30
+ }
31
+
32
+ export const HOOK_EVENTS: HookEvent[] = [
33
+ 'PreToolUse',
34
+ 'PostToolUse',
35
+ 'PreCompact',
36
+ 'Notification',
37
+ 'Stop',
38
+ ];
39
+ export const ACTION_LEVELS: ActionLevel[] = ['remind', 'warn', 'block'];
40
+
41
+ /**
42
+ * Generate a POSIX shell script for a converted hook.
43
+ * Reads JSON from stdin, matches tool/file patterns, outputs action JSON.
44
+ */
45
+ export function generateHookScript(config: HookConversionConfig): string {
46
+ const lines: string[] = [
47
+ '#!/bin/sh',
48
+ `# Converted hook: ${config.name} (Soleri Hook Pack)`,
49
+ `# Event: ${config.event} | Action: ${config.action}`,
50
+ '#',
51
+ `# ${config.message}`,
52
+ '#',
53
+ '# Dependencies: jq (required)',
54
+ '# POSIX sh compatible.',
55
+ '',
56
+ 'set -eu',
57
+ '',
58
+ 'INPUT=$(cat)',
59
+ '',
60
+ ];
61
+
62
+ if (config.event === 'PreToolUse' || config.event === 'PostToolUse') {
63
+ // Tool-based hooks read tool_name and tool_input from stdin
64
+ lines.push('# Extract tool name and input from stdin JSON');
65
+ lines.push("TOOL_NAME=$(printf '%s' \"$INPUT\" | jq -r '.tool_name // empty' 2>/dev/null)");
66
+ lines.push('');
67
+
68
+ // Tool matcher
69
+ if (config.toolMatcher) {
70
+ lines.push('# Check tool name matcher');
71
+ lines.push(`case "$TOOL_NAME" in`);
72
+ // Split on | for case pattern matching
73
+ const tools = config.toolMatcher.split('|').map((t) => t.trim());
74
+ lines.push(` ${tools.join('|')}) ;; # matched`);
75
+ lines.push(' *) exit 0 ;; # not a matching tool');
76
+ lines.push('esac');
77
+ lines.push('');
78
+ }
79
+
80
+ // File pattern matching
81
+ if (config.filePatterns && config.filePatterns.length > 0) {
82
+ lines.push('# Extract file path from tool input');
83
+ lines.push(
84
+ "FILE_PATH=$(printf '%s' \"$INPUT\" | jq -r '.tool_input.file_path // .tool_input.command // empty' 2>/dev/null)",
85
+ );
86
+ lines.push('');
87
+ lines.push('# Check file patterns');
88
+ lines.push('MATCHED=false');
89
+ for (const pattern of config.filePatterns) {
90
+ // Convert glob to grep-compatible regex
91
+ const regex = pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*');
92
+ lines.push(`printf '%s' "$FILE_PATH" | grep -qE '${regex}' && MATCHED=true`);
93
+ }
94
+ lines.push('');
95
+ lines.push('if [ "$MATCHED" = false ]; then');
96
+ lines.push(' exit 0');
97
+ lines.push('fi');
98
+ lines.push('');
99
+ }
100
+ }
101
+
102
+ // Output the action
103
+ const escapedMessage = config.message.replace(/'/g, "'\\''");
104
+
105
+ if (config.action === 'block') {
106
+ lines.push('# Block the operation');
107
+ lines.push('jq -n \\');
108
+ lines.push(` --arg msg '${escapedMessage}' \\`);
109
+ lines.push(" '{");
110
+ lines.push(' continue: false,');
111
+ lines.push(' stopReason: ("BLOCKED: " + $msg)');
112
+ lines.push(" }'");
113
+ } else if (config.action === 'warn') {
114
+ lines.push('# Warn — allow but inject context');
115
+ lines.push('jq -n \\');
116
+ lines.push(` --arg msg '${escapedMessage}' \\`);
117
+ lines.push(" '{");
118
+ lines.push(' continue: true,');
119
+ lines.push(' message: ("WARNING: " + $msg)');
120
+ lines.push(" }'");
121
+ } else {
122
+ // remind (default)
123
+ lines.push('# Remind — inject context without blocking');
124
+ lines.push('jq -n \\');
125
+ lines.push(` --arg msg '${escapedMessage}' \\`);
126
+ lines.push(" '{");
127
+ lines.push(' continue: true,');
128
+ lines.push(' message: ("REMINDER: " + $msg)');
129
+ lines.push(" }'");
130
+ }
131
+
132
+ return lines.join('\n') + '\n';
133
+ }
134
+
135
+ /**
136
+ * Generate a HookPackManifest for a converted hook.
137
+ */
138
+ export function generateManifest(config: HookConversionConfig): HookPackManifest {
139
+ const script: HookPackScript = {
140
+ name: config.name,
141
+ file: `${config.name}.sh`,
142
+ targetDir: 'hooks',
143
+ };
144
+
145
+ const lifecycleHook: HookPackLifecycleHook = {
146
+ event: config.event,
147
+ matcher: config.toolMatcher ?? '',
148
+ type: 'command',
149
+ command: `sh ~/.claude/hooks/${config.name}.sh`,
150
+ timeout: 10,
151
+ statusMessage: config.message,
152
+ };
153
+
154
+ return {
155
+ name: config.name,
156
+ version: '1.0.0',
157
+ description: config.description ?? config.message,
158
+ hooks: [],
159
+ scripts: [script],
160
+ lifecycleHooks: [lifecycleHook],
161
+ actionLevel: config.action,
162
+ };
163
+ }
@@ -0,0 +1,65 @@
1
+ # flock-guard
2
+
3
+ Parallel agent lock guard for Soleri. Prevents lockfile corruption when multiple agents run concurrently in worktrees of the same repository.
4
+
5
+ ## What it protects
6
+
7
+ Intercepts commands that modify package manager lockfiles:
8
+
9
+ | Package Manager | Commands |
10
+ | --------------- | ----------------------------- |
11
+ | npm | `npm install`, `npm ci` |
12
+ | yarn | `yarn`, `yarn install` |
13
+ | pnpm | `pnpm install` |
14
+ | cargo | `cargo build`, `cargo update` |
15
+ | pip | `pip install`, `pip3 install` |
16
+
17
+ ## How locking works
18
+
19
+ 1. **PreToolUse** hook fires before any Bash command
20
+ 2. If the command matches a lockfile-modifying pattern, the hook acquires a lock via `mkdir` (atomic on POSIX)
21
+ 3. Lock state is written to a JSON file inside the lock directory: agent ID, timestamp, command
22
+ 4. If another agent already holds the lock, the command is **blocked** with a descriptive error
23
+ 5. **PostToolUse** hook fires after the command completes and releases the lock
24
+
25
+ ### Lock path
26
+
27
+ ```
28
+ /tmp/soleri-guard-<project-hash>.lock/lock.json
29
+ ```
30
+
31
+ The project hash is derived from the git repository root path, so all worktrees of the same repository share the same lock. This is intentional — lockfile writes in any worktree can conflict at the npm/yarn cache level.
32
+
33
+ ### Reentrant locking
34
+
35
+ If the same agent (identified by `CLAUDE_SESSION_ID` or PID) already holds the lock, the hook refreshes the timestamp and allows the command through. This prevents self-deadlock when chaining multiple install commands.
36
+
37
+ ### Stale lock detection
38
+
39
+ Locks older than **30 seconds** are considered stale and automatically cleaned up. This handles the case where an agent crashes mid-install without releasing the lock.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ soleri hooks add-pack flock-guard
45
+ ```
46
+
47
+ ## Troubleshooting
48
+
49
+ ### Stuck lock
50
+
51
+ If a lock is stuck (agent crashed, machine rebooted mid-install), clear it manually:
52
+
53
+ ```bash
54
+ rm -rf /tmp/soleri-guard-*.lock
55
+ ```
56
+
57
+ ### Checking lock status
58
+
59
+ ```bash
60
+ cat /tmp/soleri-guard-*.lock/lock.json 2>/dev/null || echo "No active locks"
61
+ ```
62
+
63
+ ### Dependencies
64
+
65
+ Requires `jq` to be installed and available on PATH.