@soleri/cli 9.4.0 → 9.6.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 (73) hide show
  1. package/dist/commands/create.js +3 -6
  2. package/dist/commands/create.js.map +1 -1
  3. package/dist/commands/hooks.js +126 -0
  4. package/dist/commands/hooks.js.map +1 -1
  5. package/dist/commands/install.js +5 -0
  6. package/dist/commands/install.js.map +1 -1
  7. package/dist/hook-packs/converter/README.md +99 -0
  8. package/dist/hook-packs/converter/template.d.ts +36 -0
  9. package/dist/hook-packs/converter/template.js +127 -0
  10. package/dist/hook-packs/converter/template.js.map +1 -0
  11. package/dist/hook-packs/converter/template.test.ts +133 -0
  12. package/dist/hook-packs/converter/template.ts +163 -0
  13. package/dist/hook-packs/flock-guard/README.md +65 -0
  14. package/dist/hook-packs/flock-guard/manifest.json +36 -0
  15. package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  16. package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  17. package/dist/hook-packs/full/manifest.json +8 -1
  18. package/dist/hook-packs/graduation.d.ts +11 -0
  19. package/dist/hook-packs/graduation.js +48 -0
  20. package/dist/hook-packs/graduation.js.map +1 -0
  21. package/dist/hook-packs/graduation.ts +65 -0
  22. package/dist/hook-packs/installer.js +3 -1
  23. package/dist/hook-packs/installer.js.map +1 -1
  24. package/dist/hook-packs/installer.ts +3 -1
  25. package/dist/hook-packs/marketing-research/README.md +37 -0
  26. package/dist/hook-packs/marketing-research/manifest.json +24 -0
  27. package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  28. package/dist/hook-packs/registry.d.ts +1 -0
  29. package/dist/hook-packs/registry.js +14 -4
  30. package/dist/hook-packs/registry.js.map +1 -1
  31. package/dist/hook-packs/registry.ts +18 -4
  32. package/dist/hook-packs/safety/README.md +50 -0
  33. package/dist/hook-packs/safety/manifest.json +23 -0
  34. package/{src/hook-packs/yolo-safety → dist/hook-packs/safety}/scripts/anti-deletion.sh +7 -1
  35. package/dist/hook-packs/validator.d.ts +32 -0
  36. package/dist/hook-packs/validator.js +126 -0
  37. package/dist/hook-packs/validator.js.map +1 -0
  38. package/dist/hook-packs/validator.ts +158 -0
  39. package/dist/hook-packs/yolo-safety/manifest.json +3 -19
  40. package/dist/prompts/create-wizard.js +1 -1
  41. package/dist/prompts/create-wizard.js.map +1 -1
  42. package/dist/utils/checks.js +6 -1
  43. package/dist/utils/checks.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__tests__/flock-guard.test.ts +232 -0
  46. package/src/__tests__/graduation.test.ts +199 -0
  47. package/src/__tests__/hook-packs.test.ts +44 -19
  48. package/src/__tests__/hooks-convert.test.ts +344 -0
  49. package/src/__tests__/validator.test.ts +265 -0
  50. package/src/commands/create.ts +3 -7
  51. package/src/commands/hooks.ts +172 -0
  52. package/src/commands/install.ts +6 -0
  53. package/src/hook-packs/converter/README.md +99 -0
  54. package/src/hook-packs/converter/template.ts +163 -0
  55. package/src/hook-packs/flock-guard/README.md +65 -0
  56. package/src/hook-packs/flock-guard/manifest.json +36 -0
  57. package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
  58. package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
  59. package/src/hook-packs/full/manifest.json +8 -1
  60. package/src/hook-packs/graduation.ts +65 -0
  61. package/src/hook-packs/installer.ts +3 -1
  62. package/src/hook-packs/marketing-research/README.md +37 -0
  63. package/src/hook-packs/marketing-research/manifest.json +24 -0
  64. package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
  65. package/src/hook-packs/registry.ts +18 -4
  66. package/src/hook-packs/safety/README.md +50 -0
  67. package/src/hook-packs/safety/manifest.json +23 -0
  68. package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
  69. package/src/hook-packs/validator.ts +158 -0
  70. package/src/hook-packs/yolo-safety/manifest.json +3 -19
  71. package/src/prompts/create-wizard.ts +1 -1
  72. package/src/utils/checks.ts +6 -1
  73. package/src/prompts/playbook.ts +0 -487
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { generateFixtures, validateHookScript } from '../hook-packs/validator.js';
4
+ import type { TestFixture } from '../hook-packs/validator.js';
5
+
6
+ // Mock execSync to avoid needing actual shell scripts in tests
7
+ vi.mock('node:child_process', () => ({
8
+ execSync: vi.fn(() => ''),
9
+ }));
10
+
11
+ const mockedExecSync = vi.mocked(execSync);
12
+
13
+ describe('validator', () => {
14
+ describe('generateFixtures', () => {
15
+ it('should return 15 fixtures for PreToolUse (5 matching + 10 non-matching)', () => {
16
+ const fixtures = generateFixtures('PreToolUse', 'Write');
17
+ expect(fixtures).toHaveLength(15);
18
+
19
+ const matching = fixtures.filter((f) => f.shouldMatch);
20
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
21
+ expect(matching).toHaveLength(5);
22
+ expect(nonMatching).toHaveLength(10);
23
+ });
24
+
25
+ it('should return 15 fixtures for PostToolUse', () => {
26
+ const fixtures = generateFixtures('PostToolUse', 'Edit|Write');
27
+ expect(fixtures).toHaveLength(15);
28
+
29
+ const matching = fixtures.filter((f) => f.shouldMatch);
30
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
31
+ expect(matching).toHaveLength(5);
32
+ expect(nonMatching).toHaveLength(10);
33
+ });
34
+
35
+ it('should return 15 fixtures for PreCompact', () => {
36
+ const fixtures = generateFixtures('PreCompact');
37
+ expect(fixtures).toHaveLength(15);
38
+
39
+ const matching = fixtures.filter((f) => f.shouldMatch);
40
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
41
+ expect(matching).toHaveLength(5);
42
+ expect(nonMatching).toHaveLength(10);
43
+ });
44
+
45
+ it('should return 15 fixtures for Notification', () => {
46
+ const fixtures = generateFixtures('Notification');
47
+ expect(fixtures).toHaveLength(15);
48
+ });
49
+
50
+ it('should return 15 fixtures for Stop', () => {
51
+ const fixtures = generateFixtures('Stop');
52
+ expect(fixtures).toHaveLength(15);
53
+ });
54
+
55
+ it('matching PreToolUse fixtures should have shouldMatch: true', () => {
56
+ const fixtures = generateFixtures('PreToolUse', 'Bash');
57
+ const matching = fixtures.filter((f) => f.shouldMatch);
58
+ for (const f of matching) {
59
+ expect(f.shouldMatch).toBe(true);
60
+ }
61
+ });
62
+
63
+ it('non-matching PreToolUse fixtures should have shouldMatch: false', () => {
64
+ const fixtures = generateFixtures('PreToolUse', 'Write');
65
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
66
+ for (const f of nonMatching) {
67
+ expect(f.shouldMatch).toBe(false);
68
+ }
69
+ });
70
+
71
+ it('PreToolUse matching fixtures should contain tool_name and tool_input', () => {
72
+ const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
73
+ const matching = fixtures.filter((f) => f.shouldMatch);
74
+ for (const f of matching) {
75
+ expect(f.payload).toHaveProperty('tool_name');
76
+ expect(f.payload).toHaveProperty('tool_input');
77
+ const toolInput = f.payload.tool_input as Record<string, unknown>;
78
+ expect(toolInput).toHaveProperty('file_path');
79
+ expect(toolInput).toHaveProperty('command');
80
+ }
81
+ });
82
+
83
+ it('PreToolUse non-matching fixtures should contain tool_name and tool_input', () => {
84
+ const fixtures = generateFixtures('PreToolUse', 'Write');
85
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
86
+ for (const f of nonMatching) {
87
+ expect(f.payload).toHaveProperty('tool_name');
88
+ expect(f.payload).toHaveProperty('tool_input');
89
+ }
90
+ });
91
+
92
+ it('should use provided toolMatcher tools in matching fixtures', () => {
93
+ const fixtures = generateFixtures('PreToolUse', 'Edit|Write');
94
+ const matching = fixtures.filter((f) => f.shouldMatch);
95
+ const toolNames = matching.map((f) => f.payload.tool_name);
96
+ for (const name of toolNames) {
97
+ expect(['Edit', 'Write']).toContain(name);
98
+ }
99
+ });
100
+
101
+ it('should default to Write when no toolMatcher provided for PreToolUse', () => {
102
+ const fixtures = generateFixtures('PreToolUse');
103
+ const matching = fixtures.filter((f) => f.shouldMatch);
104
+ for (const f of matching) {
105
+ expect(f.payload.tool_name).toBe('Write');
106
+ }
107
+ });
108
+
109
+ it('PreCompact matching fixtures should have session_id', () => {
110
+ const fixtures = generateFixtures('PreCompact');
111
+ const matching = fixtures.filter((f) => f.shouldMatch);
112
+ for (const f of matching) {
113
+ expect(f.payload).toHaveProperty('session_id');
114
+ expect(f.payload).toHaveProperty('context');
115
+ }
116
+ });
117
+
118
+ it('PreCompact non-matching fixtures should have empty payloads', () => {
119
+ const fixtures = generateFixtures('PreCompact');
120
+ const nonMatching = fixtures.filter((f) => !f.shouldMatch);
121
+ for (const f of nonMatching) {
122
+ expect(Object.keys(f.payload)).toHaveLength(0);
123
+ }
124
+ });
125
+
126
+ it('all fixtures should have event matching the requested event', () => {
127
+ for (const event of [
128
+ 'PreToolUse',
129
+ 'PostToolUse',
130
+ 'PreCompact',
131
+ 'Notification',
132
+ 'Stop',
133
+ ] as const) {
134
+ const fixtures = generateFixtures(event);
135
+ for (const f of fixtures) {
136
+ expect(f.event).toBe(event);
137
+ }
138
+ }
139
+ });
140
+
141
+ it('all fixtures should have unique names', () => {
142
+ const fixtures = generateFixtures('PreToolUse', 'Write|Edit');
143
+ const names = fixtures.map((f) => f.name);
144
+ expect(new Set(names).size).toBe(names.length);
145
+ });
146
+ });
147
+
148
+ describe('validateHookScript', () => {
149
+ it('should report correctly with a script that produces no output (exit 0)', () => {
150
+ // execSync mock returns '' (empty string) — no match detected
151
+ mockedExecSync.mockReturnValue('');
152
+
153
+ const fixtures: TestFixture[] = [
154
+ {
155
+ name: 'should-match',
156
+ event: 'PreToolUse',
157
+ payload: { tool_name: 'Write', tool_input: { file_path: 'test.ts' } },
158
+ shouldMatch: true,
159
+ },
160
+ {
161
+ name: 'should-not-match',
162
+ event: 'PreToolUse',
163
+ payload: { tool_name: 'Read', tool_input: { file_path: 'test.ts' } },
164
+ shouldMatch: false,
165
+ },
166
+ ];
167
+
168
+ const report = validateHookScript('/fake/script.sh', fixtures);
169
+
170
+ expect(report.total).toBe(2);
171
+ // Script produces no output, so matched = false for all
172
+ // should-match expected match but got none -> false negative
173
+ // should-not-match expected no match and got none -> correct
174
+ expect(report.falseNegatives).toHaveLength(1);
175
+ expect(report.falseNegatives[0].fixture.name).toBe('should-match');
176
+ expect(report.falsePositives).toHaveLength(0);
177
+ expect(report.passed).toBe(1);
178
+ });
179
+
180
+ it('should detect false positives when script always matches', () => {
181
+ mockedExecSync.mockReturnValue('{"continue": true, "message": "always matches"}');
182
+
183
+ const fixtures: TestFixture[] = [
184
+ {
185
+ name: 'should-match',
186
+ event: 'PreToolUse',
187
+ payload: { tool_name: 'Write', tool_input: {} },
188
+ shouldMatch: true,
189
+ },
190
+ {
191
+ name: 'should-not-match',
192
+ event: 'PreToolUse',
193
+ payload: { tool_name: 'Read', tool_input: {} },
194
+ shouldMatch: false,
195
+ },
196
+ ];
197
+
198
+ const report = validateHookScript('/fake/script.sh', fixtures);
199
+
200
+ expect(report.total).toBe(2);
201
+ // Script always outputs "continue", so matched = true for all
202
+ // should-not-match expected no match but got one -> false positive
203
+ expect(report.falsePositives).toHaveLength(1);
204
+ expect(report.falsePositives[0].fixture.name).toBe('should-not-match');
205
+ expect(report.falseNegatives).toHaveLength(0);
206
+ expect(report.passed).toBe(1);
207
+ });
208
+
209
+ it('should report all passed when script matches correctly', () => {
210
+ mockedExecSync.mockImplementation((cmd: unknown) => {
211
+ if (typeof cmd === 'string' && cmd.includes('Write')) {
212
+ return '{"continue": true, "message": "matched"}';
213
+ }
214
+ return '';
215
+ });
216
+
217
+ const fixtures: TestFixture[] = [
218
+ {
219
+ name: 'should-match',
220
+ event: 'PreToolUse',
221
+ payload: { tool_name: 'Write', tool_input: {} },
222
+ shouldMatch: true,
223
+ },
224
+ {
225
+ name: 'should-not-match',
226
+ event: 'PreToolUse',
227
+ payload: { tool_name: 'Read', tool_input: {} },
228
+ shouldMatch: false,
229
+ },
230
+ ];
231
+
232
+ const report = validateHookScript('/fake/script.sh', fixtures);
233
+
234
+ expect(report.total).toBe(2);
235
+ expect(report.passed).toBe(2);
236
+ expect(report.falsePositives).toHaveLength(0);
237
+ expect(report.falseNegatives).toHaveLength(0);
238
+ });
239
+
240
+ it('should handle script errors gracefully (exit code != 0)', () => {
241
+ mockedExecSync.mockImplementation(() => {
242
+ const err = new Error('script failed') as Error & { status: number; stdout: string };
243
+ err.status = 1;
244
+ err.stdout = '';
245
+ throw err;
246
+ });
247
+
248
+ const fixtures: TestFixture[] = [
249
+ {
250
+ name: 'error-fixture',
251
+ event: 'PreToolUse',
252
+ payload: { tool_name: 'Write', tool_input: {} },
253
+ shouldMatch: true,
254
+ },
255
+ ];
256
+
257
+ const report = validateHookScript('/fake/script.sh', fixtures);
258
+
259
+ expect(report.total).toBe(1);
260
+ // Error means matched = false, but shouldMatch = true -> false negative
261
+ expect(report.falseNegatives).toHaveLength(1);
262
+ expect(report.passed).toBe(0);
263
+ });
264
+ });
265
+ });
@@ -1,6 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
- import { resolve, join } from 'node:path';
3
- import { homedir } from 'node:os';
2
+ import { resolve } from 'node:path';
4
3
  import type { Command } from 'commander';
5
4
  import * as p from '@clack/prompts';
6
5
  import {
@@ -15,9 +14,6 @@ import { runCreateWizard } from '../prompts/create-wizard.js';
15
14
  import { listPacks } from '../hook-packs/registry.js';
16
15
  import { installPack } from '../hook-packs/installer.js';
17
16
 
18
- /** Default parent directory for new agents: ~/.soleri/ */
19
- const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
20
-
21
17
  function parseSetupTarget(value?: string): SetupTarget | undefined {
22
18
  if (!value) return undefined;
23
19
  if ((SETUP_TARGETS as readonly string[]).includes(value)) {
@@ -41,7 +37,7 @@ export function registerCreate(program: Command): void {
41
37
  `Setup target: ${SETUP_TARGETS.join(', ')} (default: claude)`,
42
38
  )
43
39
  .option('-y, --yes', 'Skip confirmation prompts (use with --config for fully non-interactive)')
44
- .option('--dir <path>', `Parent directory for the agent (default: ~/.soleri/)`)
40
+ .option('--dir <path>', `Parent directory for the agent (default: current directory)`)
45
41
  .option('--filetree', 'Create a file-tree agent (v7 — no TypeScript, no build step)')
46
42
  .option('--legacy', 'Create a legacy TypeScript agent (v6 — requires npm install + build)')
47
43
  .description('Create a new Soleri agent')
@@ -154,7 +150,7 @@ export function registerCreate(program: Command): void {
154
150
  })),
155
151
  };
156
152
 
157
- const outputDir = opts?.dir ? resolve(opts.dir) : (config.outputDir ?? SOLERI_HOME);
153
+ const outputDir = opts?.dir ? resolve(opts.dir) : (config.outputDir ?? process.cwd());
158
154
  const nonInteractive = !!(opts?.yes || opts?.config);
159
155
 
160
156
  if (!nonInteractive) {
@@ -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 |