@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,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": ["typescript-safety", "a11y", "css-discipline", "clean-commits", "yolo-safety"]
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
- chmodSync(destPath, 0o755);
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
+ ```
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "marketing-research",
3
+ "version": "1.0.0",
4
+ "description": "Auto-research hook for marketing files — reminds to check brand guidelines, A/B testing data, and audience segmentation before editing marketing content",
5
+ "hooks": [],
6
+ "scripts": [
7
+ {
8
+ "name": "marketing-research",
9
+ "file": "marketing-research.sh",
10
+ "targetDir": "hooks"
11
+ }
12
+ ],
13
+ "lifecycleHooks": [
14
+ {
15
+ "event": "PreToolUse",
16
+ "matcher": "Write|Edit",
17
+ "type": "command",
18
+ "command": "sh ~/.claude/hooks/marketing-research.sh",
19
+ "timeout": 10,
20
+ "statusMessage": "Checking marketing context..."
21
+ }
22
+ ],
23
+ "actionLevel": "remind"
24
+ }
@@ -0,0 +1,70 @@
1
+ #!/bin/sh
2
+ # Marketing Research Hook for Claude Code (Soleri Hook Pack: marketing-research)
3
+ # PreToolUse -> Write|Edit: reminds to check brand guidelines, A/B testing data,
4
+ # and audience segmentation before editing marketing content.
5
+ #
6
+ # Matched file patterns:
7
+ # - **/marketing/**
8
+ # - **/*marketing*
9
+ # - **/campaign*/**
10
+ #
11
+ # Dependencies: jq (required)
12
+ # POSIX sh compatible — no bash-specific features.
13
+
14
+ set -eu
15
+
16
+ INPUT=$(cat)
17
+
18
+ # Extract tool name
19
+ TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
20
+
21
+ # Only act on Write or Edit
22
+ case "$TOOL_NAME" in
23
+ Write|Edit) ;;
24
+ *) exit 0 ;;
25
+ esac
26
+
27
+ # Extract file_path from tool_input
28
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
29
+
30
+ # No file path — let it through
31
+ if [ -z "$FILE_PATH" ]; then
32
+ exit 0
33
+ fi
34
+
35
+ # Check if file matches marketing patterns
36
+ IS_MARKETING=false
37
+
38
+ # Pattern: **/marketing/** — file is inside a marketing directory
39
+ case "$FILE_PATH" in
40
+ */marketing/*) IS_MARKETING=true ;;
41
+ esac
42
+
43
+ # Pattern: **/*marketing* — filename or path component contains "marketing"
44
+ if [ "$IS_MARKETING" = false ]; then
45
+ BASENAME=$(basename "$FILE_PATH")
46
+ case "$BASENAME" in
47
+ *marketing*) IS_MARKETING=true ;;
48
+ esac
49
+ fi
50
+
51
+ # Pattern: **/campaign*/** — file is inside a campaign* directory
52
+ if [ "$IS_MARKETING" = false ]; then
53
+ # Check if any path component starts with "campaign"
54
+ if printf '%s' "$FILE_PATH" | grep -qE '/(campaign[^/]*)/'; then
55
+ IS_MARKETING=true
56
+ fi
57
+ fi
58
+
59
+ # Not a marketing file — let it through
60
+ if [ "$IS_MARKETING" = false ]; then
61
+ exit 0
62
+ fi
63
+
64
+ # Output remind JSON
65
+ jq -n \
66
+ --arg file "$FILE_PATH" \
67
+ '{
68
+ continue: true,
69
+ message: ("Marketing file detected: " + $file + "\n\nBefore editing, consider checking:\n- Brand guidelines — tone, voice, approved terminology\n- A/B testing data — what messaging has performed well\n- Audience segmentation — who is the target for this content\n- Campaign calendar — timing and coordination with other assets")
70
+ }'
@@ -30,6 +30,7 @@ export interface HookPackManifest {
30
30
  scripts?: HookPackScript[];
31
31
  lifecycleHooks?: HookPackLifecycleHook[];
32
32
  source?: 'built-in' | 'local';
33
+ actionLevel?: 'remind' | 'warn' | 'block';
33
34
  }
34
35
 
35
36
  const __filename = fileURLToPath(import.meta.url);
@@ -97,7 +98,9 @@ export function getPack(name: string): { manifest: HookPackManifest; dir: string
97
98
  export function getInstalledPacks(): string[] {
98
99
  const claudeDir = join(homedir(), '.claude');
99
100
  const packs = listPacks();
100
- const installed: string[] = [];
101
+ const installed = new Set<string>();
102
+
103
+ // First pass: detect directly installed packs (hooks or scripts)
101
104
  for (const pack of packs) {
102
105
  if (pack.hooks.length === 0) {
103
106
  if (pack.scripts && pack.scripts.length > 0) {
@@ -105,7 +108,7 @@ export function getInstalledPacks(): string[] {
105
108
  existsSync(join(claudeDir, script.targetDir, script.file)),
106
109
  );
107
110
  if (allScripts) {
108
- installed.push(pack.name);
111
+ installed.add(pack.name);
109
112
  }
110
113
  }
111
114
  continue;
@@ -114,8 +117,19 @@ export function getInstalledPacks(): string[] {
114
117
  existsSync(join(claudeDir, `hookify.${hook}.local.md`)),
115
118
  );
116
119
  if (allPresent) {
117
- installed.push(pack.name);
120
+ installed.add(pack.name);
118
121
  }
119
122
  }
120
- return installed;
123
+
124
+ // Second pass: composed packs are installed if all sub-packs are installed
125
+ for (const pack of packs) {
126
+ if (pack.composedFrom && pack.composedFrom.length > 0 && !installed.has(pack.name)) {
127
+ const allSubsInstalled = pack.composedFrom.every((sub) => installed.has(sub));
128
+ if (allSubsInstalled) {
129
+ installed.add(pack.name);
130
+ }
131
+ }
132
+ }
133
+
134
+ return Array.from(installed);
121
135
  }