@soleri/cli 9.3.1 → 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.
- package/dist/commands/agent.js +51 -2
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/hooks.js +126 -0
- package/dist/commands/hooks.js.map +1 -1
- package/dist/commands/install.js +5 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/pack.js +62 -13
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/staging.d.ts +49 -0
- package/dist/commands/staging.js +108 -18
- package/dist/commands/staging.js.map +1 -1
- package/dist/commands/yolo.d.ts +2 -0
- package/dist/commands/yolo.js +86 -0
- package/dist/commands/yolo.js.map +1 -0
- package/dist/hook-packs/converter/README.md +99 -0
- package/dist/hook-packs/converter/template.d.ts +36 -0
- package/dist/hook-packs/converter/template.js +127 -0
- package/dist/hook-packs/converter/template.js.map +1 -0
- package/dist/hook-packs/converter/template.test.ts +133 -0
- package/dist/hook-packs/converter/template.ts +163 -0
- package/dist/hook-packs/flock-guard/README.md +65 -0
- package/dist/hook-packs/flock-guard/manifest.json +36 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/dist/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/dist/hook-packs/full/manifest.json +8 -1
- package/dist/hook-packs/graduation.d.ts +11 -0
- package/dist/hook-packs/graduation.js +48 -0
- package/dist/hook-packs/graduation.js.map +1 -0
- package/dist/hook-packs/graduation.ts +65 -0
- package/dist/hook-packs/installer.js +3 -1
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +3 -1
- package/dist/hook-packs/marketing-research/README.md +37 -0
- package/dist/hook-packs/marketing-research/manifest.json +24 -0
- package/dist/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/dist/hook-packs/registry.d.ts +1 -0
- package/dist/hook-packs/registry.js +14 -4
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +18 -4
- package/dist/hook-packs/safety/README.md +50 -0
- package/dist/hook-packs/safety/manifest.json +23 -0
- package/dist/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/dist/hook-packs/validator.d.ts +32 -0
- package/dist/hook-packs/validator.js +126 -0
- package/dist/hook-packs/validator.js.map +1 -0
- package/dist/hook-packs/validator.ts +158 -0
- package/dist/hook-packs/yolo-safety/manifest.json +3 -19
- package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +121 -61
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/flock-guard.test.ts +225 -0
- package/src/__tests__/graduation.test.ts +199 -0
- package/src/__tests__/hook-packs.test.ts +45 -20
- package/src/__tests__/hooks-convert.test.ts +342 -0
- package/src/__tests__/validator.test.ts +265 -0
- package/src/__tests__/wizard-e2e.mjs +1 -1
- package/src/commands/agent.ts +65 -2
- package/src/commands/hooks.ts +172 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/pack.ts +80 -14
- package/src/commands/staging.ts +143 -20
- package/src/commands/yolo.ts +103 -0
- package/src/hook-packs/converter/README.md +99 -0
- package/src/hook-packs/converter/template.test.ts +133 -0
- package/src/hook-packs/converter/template.ts +163 -0
- package/src/hook-packs/flock-guard/README.md +65 -0
- package/src/hook-packs/flock-guard/manifest.json +36 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-post.sh +48 -0
- package/src/hook-packs/flock-guard/scripts/flock-guard-pre.sh +85 -0
- package/src/hook-packs/full/manifest.json +8 -1
- package/src/hook-packs/graduation.ts +65 -0
- package/src/hook-packs/installer.ts +3 -1
- package/src/hook-packs/marketing-research/README.md +37 -0
- package/src/hook-packs/marketing-research/manifest.json +24 -0
- package/src/hook-packs/marketing-research/scripts/marketing-research.sh +70 -0
- package/src/hook-packs/registry.ts +18 -4
- package/src/hook-packs/safety/README.md +50 -0
- package/src/hook-packs/safety/manifest.json +23 -0
- package/src/hook-packs/safety/scripts/anti-deletion.sh +280 -0
- package/src/hook-packs/validator.ts +158 -0
- package/src/hook-packs/yolo-safety/manifest.json +3 -19
- package/src/main.ts +2 -0
- package/vitest.config.ts +1 -0
- package/src/__tests__/archetypes.test.ts +0 -84
- package/src/__tests__/create.test.ts +0 -207
- package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +0 -214
- package/src/prompts/archetypes.ts +0 -343
|
@@ -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.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flock-guard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Parallel agent lock guard — prevents lockfile corruption when multiple agents run in worktrees by using atomic mkdir-based locking across PreToolUse/PostToolUse",
|
|
5
|
+
"hooks": [],
|
|
6
|
+
"scripts": [
|
|
7
|
+
{
|
|
8
|
+
"name": "flock-guard-pre",
|
|
9
|
+
"file": "flock-guard-pre.sh",
|
|
10
|
+
"targetDir": "hooks"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "flock-guard-post",
|
|
14
|
+
"file": "flock-guard-post.sh",
|
|
15
|
+
"targetDir": "hooks"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"lifecycleHooks": [
|
|
19
|
+
{
|
|
20
|
+
"event": "PreToolUse",
|
|
21
|
+
"matcher": "Bash",
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "sh ~/.claude/hooks/flock-guard-pre.sh",
|
|
24
|
+
"timeout": 10,
|
|
25
|
+
"statusMessage": "Checking lockfile guard..."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"event": "PostToolUse",
|
|
29
|
+
"matcher": "Bash",
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "sh ~/.claude/hooks/flock-guard-post.sh",
|
|
32
|
+
"timeout": 10,
|
|
33
|
+
"statusMessage": "Releasing lockfile guard..."
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Flock Guard — PostToolUse lock release (Soleri Hook Pack: flock-guard)
|
|
3
|
+
# Releases the lock after a lockfile-modifying command completes.
|
|
4
|
+
# Dependencies: jq
|
|
5
|
+
# POSIX sh compatible.
|
|
6
|
+
|
|
7
|
+
set -eu
|
|
8
|
+
|
|
9
|
+
INPUT=$(cat)
|
|
10
|
+
|
|
11
|
+
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
12
|
+
if [ -z "$CMD" ]; then
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Strip quoted strings (same logic as pre script)
|
|
17
|
+
STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
|
|
18
|
+
STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
|
|
19
|
+
STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
|
|
20
|
+
|
|
21
|
+
# Check if command modifies lockfiles (same patterns as pre)
|
|
22
|
+
IS_LOCKFILE_CMD=false
|
|
23
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+(install|ci)(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
24
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)yarn(\s+install)?(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
25
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pnpm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
26
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)cargo\s+(build|update)(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
27
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pip[3]?\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
28
|
+
|
|
29
|
+
if [ "$IS_LOCKFILE_CMD" = false ]; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Release lock
|
|
34
|
+
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
35
|
+
PROJECT_HASH=$(printf '%s' "$PROJECT_ROOT" | shasum | cut -c1-8)
|
|
36
|
+
LOCK_DIR="${TMPDIR:-${TEMP:-/tmp}}/soleri-guard-${PROJECT_HASH}.lock"
|
|
37
|
+
|
|
38
|
+
# Only release if we own it
|
|
39
|
+
SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
|
|
40
|
+
if [ -f "$LOCK_DIR/lock.json" ]; then
|
|
41
|
+
LOCK_AGENT=$(jq -r '.agentId // ""' "$LOCK_DIR/lock.json" 2>/dev/null || echo "")
|
|
42
|
+
if [ "$LOCK_AGENT" = "$SESSION_ID" ]; then
|
|
43
|
+
rm -rf "$LOCK_DIR"
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# PostToolUse never blocks
|
|
48
|
+
exit 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Flock Guard — PreToolUse lock acquisition (Soleri Hook Pack: flock-guard)
|
|
3
|
+
# Acquires atomic mkdir-based lock before lockfile-modifying commands.
|
|
4
|
+
# Dependencies: jq
|
|
5
|
+
# POSIX sh compatible.
|
|
6
|
+
|
|
7
|
+
set -eu
|
|
8
|
+
|
|
9
|
+
INPUT=$(cat)
|
|
10
|
+
|
|
11
|
+
# Extract command from stdin JSON
|
|
12
|
+
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
13
|
+
if [ -z "$CMD" ]; then
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Strip quoted strings to avoid false positives (same as anti-deletion.sh)
|
|
18
|
+
STRIPPED=$(printf '%s' "$CMD" | sed -e "s/<<'[A-Za-z_]*'.*//g" -e 's/<<[A-Za-z_]*.*//g' 2>/dev/null || printf '%s' "$CMD")
|
|
19
|
+
STRIPPED=$(printf '%s' "$STRIPPED" | sed 's/"[^"]*"//g' 2>/dev/null || printf '%s' "$STRIPPED")
|
|
20
|
+
STRIPPED=$(printf '%s' "$STRIPPED" | sed "s/'[^']*'//g" 2>/dev/null || printf '%s' "$STRIPPED")
|
|
21
|
+
|
|
22
|
+
# Check if command modifies lockfiles
|
|
23
|
+
IS_LOCKFILE_CMD=false
|
|
24
|
+
# npm install (but not npm run, npm test, etc.)
|
|
25
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
26
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)npm\s+ci(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
27
|
+
# yarn (bare yarn or yarn install)
|
|
28
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)yarn(\s+install)?(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
29
|
+
# pnpm install
|
|
30
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pnpm\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
31
|
+
# cargo build / cargo update
|
|
32
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)cargo\s+(build|update)(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
33
|
+
# pip install
|
|
34
|
+
printf '%s' "$STRIPPED" | grep -qE '(^|\s|;|&&|\|\|)pip[3]?\s+install(\s|$|;)' && IS_LOCKFILE_CMD=true
|
|
35
|
+
|
|
36
|
+
if [ "$IS_LOCKFILE_CMD" = false ]; then
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Compute project-specific lock path
|
|
41
|
+
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
42
|
+
PROJECT_HASH=$(printf '%s' "$PROJECT_ROOT" | shasum | cut -c1-8)
|
|
43
|
+
LOCK_DIR="${TMPDIR:-${TEMP:-/tmp}}/soleri-guard-${PROJECT_HASH}.lock"
|
|
44
|
+
LOCK_JSON="$LOCK_DIR/lock.json"
|
|
45
|
+
STALE_TIMEOUT=30
|
|
46
|
+
SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
|
|
47
|
+
|
|
48
|
+
# Try to acquire lock (mkdir is atomic on POSIX)
|
|
49
|
+
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
50
|
+
# Lock acquired — write state
|
|
51
|
+
printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$(date +%s)" "$CMD" > "$LOCK_JSON"
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Lock held — check staleness
|
|
56
|
+
if [ -f "$LOCK_JSON" ]; then
|
|
57
|
+
LOCK_TIME=$(jq -r '.timestamp // 0' "$LOCK_JSON" 2>/dev/null || echo 0)
|
|
58
|
+
NOW=$(date +%s)
|
|
59
|
+
AGE=$((NOW - LOCK_TIME))
|
|
60
|
+
|
|
61
|
+
LOCK_AGENT=$(jq -r '.agentId // "unknown"' "$LOCK_JSON" 2>/dev/null || echo "unknown")
|
|
62
|
+
|
|
63
|
+
# Same agent — allow reentry
|
|
64
|
+
if [ "$LOCK_AGENT" = "$SESSION_ID" ]; then
|
|
65
|
+
# Refresh timestamp
|
|
66
|
+
printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$NOW" "$CMD" > "$LOCK_JSON"
|
|
67
|
+
exit 0
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Stale lock — clean and retry
|
|
71
|
+
if [ "$AGE" -gt "$STALE_TIMEOUT" ]; then
|
|
72
|
+
rm -rf "$LOCK_DIR"
|
|
73
|
+
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
74
|
+
printf '{"agentId":"%s","timestamp":%d,"command":"%s"}' "$SESSION_ID" "$NOW" "$CMD" > "$LOCK_JSON"
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
fi
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# Lock held by another active agent — block
|
|
81
|
+
LOCK_AGENT=$(jq -r '.agentId // "another agent"' "$LOCK_JSON" 2>/dev/null || echo "another agent")
|
|
82
|
+
jq -n --arg agent "$LOCK_AGENT" '{
|
|
83
|
+
continue: false,
|
|
84
|
+
stopReason: ("BLOCKED: Another agent (" + $agent + ") is modifying lockfiles. Wait for it to finish, then retry. Lock auto-expires after 30s if the agent crashes.")
|
|
85
|
+
}'
|
|
@@ -12,5 +12,12 @@
|
|
|
12
12
|
"ux-touch-targets",
|
|
13
13
|
"no-ai-attribution"
|
|
14
14
|
],
|
|
15
|
-
"composedFrom": [
|
|
15
|
+
"composedFrom": [
|
|
16
|
+
"typescript-safety",
|
|
17
|
+
"a11y",
|
|
18
|
+
"css-discipline",
|
|
19
|
+
"clean-commits",
|
|
20
|
+
"safety",
|
|
21
|
+
"yolo-safety"
|
|
22
|
+
]
|
|
16
23
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook pack graduation — promote/demote action levels.
|
|
3
|
+
* remind → warn → block (promote)
|
|
4
|
+
* block → warn → remind (demote)
|
|
5
|
+
*/
|
|
6
|
+
import { writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { getPack } from './registry.js';
|
|
9
|
+
import type { HookPackManifest } from './registry.js';
|
|
10
|
+
|
|
11
|
+
const LEVELS = ['remind', 'warn', 'block'] as const;
|
|
12
|
+
type ActionLevel = (typeof LEVELS)[number];
|
|
13
|
+
|
|
14
|
+
export interface GraduationResult {
|
|
15
|
+
packName: string;
|
|
16
|
+
previousLevel: ActionLevel;
|
|
17
|
+
newLevel: ActionLevel;
|
|
18
|
+
manifestPath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getCurrentLevel(manifest: HookPackManifest): ActionLevel {
|
|
22
|
+
const level = manifest.actionLevel;
|
|
23
|
+
if (level && (LEVELS as readonly string[]).includes(level)) return level as ActionLevel;
|
|
24
|
+
return 'remind'; // default
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function promotePack(packName: string): GraduationResult {
|
|
28
|
+
const pack = getPack(packName);
|
|
29
|
+
if (!pack) throw new Error(`Unknown hook pack: "${packName}"`);
|
|
30
|
+
|
|
31
|
+
const manifestPath = join(pack.dir, 'manifest.json');
|
|
32
|
+
const manifest = pack.manifest;
|
|
33
|
+
const current = getCurrentLevel(manifest);
|
|
34
|
+
const currentIndex = LEVELS.indexOf(current);
|
|
35
|
+
|
|
36
|
+
if (currentIndex >= LEVELS.length - 1) {
|
|
37
|
+
throw new Error(`Pack "${packName}" is already at maximum level: ${current}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const newLevel = LEVELS[currentIndex + 1];
|
|
41
|
+
manifest.actionLevel = newLevel;
|
|
42
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
43
|
+
|
|
44
|
+
return { packName, previousLevel: current, newLevel, manifestPath };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function demotePack(packName: string): GraduationResult {
|
|
48
|
+
const pack = getPack(packName);
|
|
49
|
+
if (!pack) throw new Error(`Unknown hook pack: "${packName}"`);
|
|
50
|
+
|
|
51
|
+
const manifestPath = join(pack.dir, 'manifest.json');
|
|
52
|
+
const manifest = pack.manifest;
|
|
53
|
+
const current = getCurrentLevel(manifest);
|
|
54
|
+
const currentIndex = LEVELS.indexOf(current);
|
|
55
|
+
|
|
56
|
+
if (currentIndex <= 0) {
|
|
57
|
+
throw new Error(`Pack "${packName}" is already at minimum level: ${current}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const newLevel = LEVELS[currentIndex - 1];
|
|
61
|
+
manifest.actionLevel = newLevel;
|
|
62
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
63
|
+
|
|
64
|
+
return { packName, previousLevel: current, newLevel, manifestPath };
|
|
65
|
+
}
|
|
@@ -195,7 +195,9 @@ export function installPack(
|
|
|
195
195
|
mkdirSync(destDir, { recursive: true });
|
|
196
196
|
const destPath = join(destDir, file);
|
|
197
197
|
copyFileSync(sourcePath, destPath);
|
|
198
|
-
|
|
198
|
+
if (process.platform !== 'win32') {
|
|
199
|
+
chmodSync(destPath, 0o755);
|
|
200
|
+
}
|
|
199
201
|
installedScripts.push(`${targetDir}/${file}`);
|
|
200
202
|
}
|
|
201
203
|
const lcHooks = resolveLifecycleHooks(packName);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Marketing Research Hook Pack
|
|
2
|
+
|
|
3
|
+
Example hook demonstrating the skill-to-hook conversion workflow. Automatically reminds you to check brand guidelines and A/B testing data when editing marketing files.
|
|
4
|
+
|
|
5
|
+
## Conversion Score
|
|
6
|
+
|
|
7
|
+
| Dimension | Score | Rationale |
|
|
8
|
+
| ----------------- | ----- | ----------------------------------------------------------- |
|
|
9
|
+
| Frequency | HIGH | 4+ manual checks per session when editing marketing content |
|
|
10
|
+
| Event Correlation | HIGH | Always triggers on Write/Edit to marketing files |
|
|
11
|
+
| Determinism | HIGH | Lookups and context injection, not creative guidance |
|
|
12
|
+
| Autonomy | HIGH | No interactive decisions needed |
|
|
13
|
+
|
|
14
|
+
**Score: 4/4 — Strong candidate**
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
soleri hooks add-pack marketing-research
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What It Does
|
|
23
|
+
|
|
24
|
+
Fires on `Write` and `Edit` tool calls when the target file matches marketing patterns:
|
|
25
|
+
|
|
26
|
+
- `**/marketing/**`
|
|
27
|
+
- `**/*marketing*`
|
|
28
|
+
- `**/campaign*/**`
|
|
29
|
+
|
|
30
|
+
Injects a reminder with brand guidelines context. Does not block — action level is `remind`.
|
|
31
|
+
|
|
32
|
+
## Graduation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
soleri hooks promote marketing-research # remind → warn
|
|
36
|
+
soleri hooks promote marketing-research # warn → block
|
|
37
|
+
```
|