uv-suite 0.19.0 → 0.21.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/bin/cli.js CHANGED
@@ -87,16 +87,80 @@ function personaLabel(p) {
87
87
  }
88
88
 
89
89
  function ensureInstalled(persona) {
90
- const settings = path.resolve('.claude/personas', `${persona}.json`);
91
- if (!fs.existsSync(settings)) {
92
- console.log('UV Suite not installed in this project. Installing...');
90
+ const hooksDir = path.resolve('.claude/hooks');
91
+ const personasDir = path.resolve('.claude/personas');
92
+ const needsInstall = !fs.existsSync(personasDir) || !fs.existsSync(hooksDir);
93
+
94
+ if (needsInstall) {
95
+ console.log('UV Suite not installed in this project. Installing core files...');
93
96
  console.log('');
94
- const installScript = path.join(UV_SUITE_DIR, 'install.sh');
95
- try {
96
- execSync(`bash "${installScript}" --persona ${persona}`, { stdio: 'inherit', timeout: 60000 });
97
- } catch (e) {
98
- // Install may timeout on pip installs but core files are already copied
97
+
98
+ // Fast install: copy essential files directly (no pip, no brew, no slow stuff)
99
+ const srcDir = UV_SUITE_DIR;
100
+ const targetDir = path.resolve('.claude');
101
+
102
+ // Create directories
103
+ for (const dir of ['agents', 'skills', 'hooks', 'rules', 'personas']) {
104
+ fs.mkdirSync(path.join(targetDir, dir), { recursive: true });
105
+ }
106
+
107
+ // Copy agents
108
+ const agentsSrc = path.join(srcDir, 'agents', 'claude-code');
109
+ if (fs.existsSync(agentsSrc)) {
110
+ for (const f of fs.readdirSync(agentsSrc)) {
111
+ fs.copyFileSync(path.join(agentsSrc, f), path.join(targetDir, 'agents', f));
112
+ }
113
+ }
114
+
115
+ // Copy hooks
116
+ const hooksSrc = path.join(srcDir, 'hooks');
117
+ if (fs.existsSync(hooksSrc)) {
118
+ for (const f of fs.readdirSync(hooksSrc)) {
119
+ const dest = path.join(targetDir, 'hooks', f);
120
+ fs.copyFileSync(path.join(hooksSrc, f), dest);
121
+ fs.chmodSync(dest, 0o755);
122
+ }
99
123
  }
124
+
125
+ // Copy skills
126
+ const skillsSrc = path.join(srcDir, 'skills');
127
+ if (fs.existsSync(skillsSrc)) {
128
+ for (const d of fs.readdirSync(skillsSrc)) {
129
+ const skillFile = path.join(skillsSrc, d, 'SKILL.md');
130
+ if (fs.existsSync(skillFile)) {
131
+ const destDir = path.join(targetDir, 'skills', d);
132
+ fs.mkdirSync(destDir, { recursive: true });
133
+ fs.copyFileSync(skillFile, path.join(destDir, 'SKILL.md'));
134
+ }
135
+ }
136
+ }
137
+
138
+ // Copy guardrails (for professional and auto)
139
+ if (persona === 'professional' || persona === 'auto') {
140
+ const guardSrc = path.join(srcDir, 'guardrails');
141
+ if (fs.existsSync(guardSrc)) {
142
+ for (const f of fs.readdirSync(guardSrc)) {
143
+ fs.copyFileSync(path.join(guardSrc, f), path.join(targetDir, 'rules', f));
144
+ }
145
+ }
146
+ }
147
+
148
+ // Copy personas
149
+ const personasSrc = path.join(srcDir, 'personas');
150
+ if (fs.existsSync(personasSrc)) {
151
+ for (const f of fs.readdirSync(personasSrc)) {
152
+ fs.copyFileSync(path.join(personasSrc, f), path.join(targetDir, 'personas', f));
153
+ }
154
+ }
155
+
156
+ // Set settings.json from persona
157
+ const personaFile = path.join(targetDir, 'personas', `${persona}.json`);
158
+ const settingsFile = path.join(targetDir, 'settings.json');
159
+ if (fs.existsSync(personaFile) && !fs.existsSync(settingsFile)) {
160
+ fs.copyFileSync(personaFile, settingsFile);
161
+ }
162
+
163
+ console.log(` Installed: agents, skills, hooks, guardrails, personas`);
100
164
  console.log('');
101
165
  }
102
166
  }
@@ -1,24 +1,37 @@
1
1
  #!/bin/bash
2
2
  # UV Suite Hook Helper: Send event to Watchtower server
3
- # Called by other hooks. Non-blocking. Fails silently if server not running.
3
+ # Called by other hooks or directly from persona hook config.
4
+ # Non-blocking. Fails silently if server not running.
4
5
  #
5
- # Usage: echo "$INPUT" | .claude/hooks/watchtower-send.sh "EventType"
6
+ # Usage from hook config:
7
+ # "command": ".claude/hooks/watchtower-send.sh PostToolUse"
8
+ # The hook input JSON comes via stdin from Claude Code.
6
9
 
7
10
  EVENT_TYPE="${1:-Unknown}"
8
11
  INPUT=$(cat)
9
12
  WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
10
13
 
11
- # Extract useful fields from the hook input
12
- SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
13
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
14
- TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null)
15
- CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
16
- SOURCE_APP=$(basename "$CWD" 2>/dev/null)
14
+ # Forward the full hook input to the watchtower, adding event_type and source_app
15
+ # jq merges the original JSON with our extra fields
16
+ PAYLOAD=$(echo "$INPUT" | jq -c ". + {
17
+ event_type: \"$EVENT_TYPE\",
18
+ source_app: (.cwd // \"\" | split(\"/\") | last),
19
+ _hook_ts: now
20
+ }" 2>/dev/null)
21
+
22
+ # Fallback if jq isn't available or fails
23
+ if [ -z "$PAYLOAD" ] || [ "$PAYLOAD" = "null" ]; then
24
+ SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
25
+ TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
26
+ CWD=$(echo "$INPUT" | grep -o '"cwd":"[^"]*"' | head -1 | cut -d'"' -f4)
27
+ SOURCE_APP=$(basename "$CWD" 2>/dev/null)
28
+ PAYLOAD="{\"event_type\":\"$EVENT_TYPE\",\"session_id\":\"$SESSION_ID\",\"source_app\":\"$SOURCE_APP\",\"tool_name\":\"$TOOL_NAME\",\"cwd\":\"$CWD\"}"
29
+ fi
17
30
 
18
31
  # Send to watchtower (non-blocking, fire-and-forget)
19
32
  curl -s -X POST "$WATCHTOWER_URL/events" \
20
33
  -H "Content-Type: application/json" \
21
- -d "{\"event_type\":\"$EVENT_TYPE\",\"session_id\":\"$SESSION_ID\",\"source_app\":\"$SOURCE_APP\",\"tool_name\":\"$TOOL_NAME\",\"tool_input\":$TOOL_INPUT,\"cwd\":\"$CWD\"}" \
34
+ -d "$PAYLOAD" \
22
35
  &>/dev/null &
23
36
 
24
37
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
5
5
  "author": "Utsav Anand",
6
6
  "license": "MIT",
@@ -23,20 +23,35 @@
23
23
  .stat { text-align: center; }
24
24
  .stat .n { font-size: 20px; font-weight: 600; }
25
25
  .stat .l { font-size: 10px; color: #86868b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
26
+ .stat .n.alert { color: #ff375f; }
26
27
 
27
28
  .sessions { padding: 12px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
28
29
  .session-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
29
30
  .session-tag:hover { border-color: #424245; }
30
31
  .session-tag.active { border-color: #fff; }
31
32
 
32
- .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 220px); }
33
- .event { padding: 6px 24px; display: grid; grid-template-columns: 70px 100px 120px 1fr 80px; gap: 8px; align-items: center; font-size: 12px; border-bottom: 1px solid #0d0d0d; }
33
+ .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 240px); }
34
+ .event { padding: 8px 24px; display: grid; grid-template-columns: 70px 110px 120px 60px 1fr; gap: 8px; align-items: start; font-size: 12px; border-bottom: 1px solid #0d0d0d; transition: background 0.15s; }
34
35
  .event:hover { background: #0d0d0d; }
35
36
  .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
36
37
  .event .type { font-weight: 500; }
37
38
  .event .session { font-size: 11px; border-radius: 8px; padding: 1px 8px; display: inline-block; }
38
- .event .detail { color: #a1a1a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
- .event .duration { color: #6e6e73; font-family: 'SF Mono', monospace; font-size: 11px; text-align: right; }
39
+ .event .tool { color: #a1a1a6; font-family: 'SF Mono', monospace; font-size: 11px; }
40
+ .event .detail { color: #a1a1a6; font-size: 11px; line-height: 1.4; }
41
+ .event .detail .filepath { color: #64d2ff; }
42
+ .event .detail .cmd { color: #ffd60a; }
43
+
44
+ /* Human intervention — pulsing highlight */
45
+ .event.needs-human { background: #ff375f12; border-left: 3px solid #ff375f; }
46
+ .event.needs-human .type { color: #ff375f; }
47
+ .event.needs-human::after { content: 'NEEDS HUMAN'; position: absolute; right: 24px; font-size: 9px; font-weight: 700; color: #ff375f; letter-spacing: 0.5px; }
48
+ .event.needs-human { position: relative; }
49
+
50
+ /* Failure highlight */
51
+ .event.failure { background: #ff696112; border-left: 3px solid #ff6961; }
52
+
53
+ /* Session start/end highlight */
54
+ .event.session-boundary { background: #30d15808; }
40
55
 
41
56
  .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
42
57
  .empty p { margin-top: 8px; font-size: 13px; }
@@ -67,6 +82,7 @@
67
82
  <div class="stat"><div class="n" id="eventCount">0</div><div class="l">Events</div></div>
68
83
  <div class="stat"><div class="n" id="toolCount">0</div><div class="l">Tool calls</div></div>
69
84
  <div class="stat"><div class="n" id="errorCount">0</div><div class="l">Errors</div></div>
85
+ <div class="stat"><div class="n" id="humanCount">0</div><div class="l">Need human</div></div>
70
86
  </div>
71
87
 
72
88
  <div class="sessions" id="sessionBar"></div>
@@ -74,6 +90,7 @@
74
90
  <div class="filters">
75
91
  <select id="filterType"><option value="">All events</option></select>
76
92
  <select id="filterSession"><option value="">All sessions</option></select>
93
+ <button id="btnHumanOnly" onclick="toggleHumanOnly()">Human needed</button>
77
94
  <button id="btnClear" onclick="clearEvents()">Clear</button>
78
95
  <button id="btnAutoScroll" class="active" onclick="toggleAutoScroll()">Auto-scroll</button>
79
96
  </div>
@@ -97,9 +114,9 @@ const sessionBar = document.getElementById('sessionBar');
97
114
  let events = [];
98
115
  let sessions = {};
99
116
  let autoScroll = true;
117
+ let humanOnly = false;
100
118
  let selectedSession = '';
101
119
  let selectedType = '';
102
- let ws;
103
120
 
104
121
  // Session colors
105
122
  const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
@@ -125,31 +142,102 @@ function shortSession(id) {
125
142
  return id.length > 12 ? id.slice(0, 8) + '...' : id;
126
143
  }
127
144
 
145
+ // Detect if event needs human intervention
146
+ function needsHuman(ev) {
147
+ const type = ev.event_type || ev.hook_event_name || '';
148
+ // Permission requests always need human
149
+ if (type === 'PermissionRequest') return true;
150
+ // Notifications that are permission prompts
151
+ if (type === 'Notification' && ev.notification_type === 'permission_prompt') return true;
152
+ // Tool failures after multiple attempts
153
+ if (type === 'PostToolUseFailure') return true;
154
+ // Stuck indicators in messages
155
+ if (ev.message && /stuck|blocked|escalat|need.*help|can.?t.*find/i.test(ev.message)) return true;
156
+ return false;
157
+ }
158
+
159
+ function isFailure(ev) {
160
+ const type = ev.event_type || ev.hook_event_name || '';
161
+ return type.includes('Failure') || type === 'StopFailure';
162
+ }
163
+
164
+ function isSessionBoundary(ev) {
165
+ const type = ev.event_type || ev.hook_event_name || '';
166
+ return type === 'SessionStart' || type === 'SessionEnd' || type === 'Stop';
167
+ }
168
+
169
+ // Build rich detail string
128
170
  function eventDetail(ev) {
129
- if (ev.tool_name) return ev.tool_name + (ev.tool_input?.command ? ': ' + ev.tool_input.command.slice(0, 60) : '');
130
- if (ev.tool_input?.file_path) return ev.tool_input.file_path;
131
- if (ev.source_app) return ev.source_app;
132
- if (ev.message) return ev.message;
133
- return '';
171
+ const parts = [];
172
+ const tool = ev.tool_name || '';
173
+ const input = ev.tool_input || {};
174
+
175
+ if (tool === 'Read' && input.file_path) {
176
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
177
+ if (input.offset) parts.push(`offset:${input.offset}`);
178
+ if (input.limit) parts.push(`limit:${input.limit}`);
179
+ } else if (tool === 'Write' && input.file_path) {
180
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
181
+ if (input.content) parts.push(`(${input.content.length} chars)`);
182
+ } else if (tool === 'Edit' && input.file_path) {
183
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
184
+ if (input.old_string) parts.push(`replacing "${input.old_string.slice(0, 30)}..."`);
185
+ } else if (tool === 'Bash' && input.command) {
186
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
187
+ if (input.timeout) parts.push(`timeout:${input.timeout}ms`);
188
+ } else if (tool === 'Grep' && input.pattern) {
189
+ parts.push(`pattern: "${input.pattern}"`);
190
+ if (input.path) parts.push(`in <span class="filepath">${input.path}</span>`);
191
+ } else if (tool === 'Glob' && input.pattern) {
192
+ parts.push(`<span class="filepath">${input.pattern}</span>`);
193
+ } else if (tool === 'Agent') {
194
+ parts.push(input.description || input.prompt?.slice(0, 60) || 'subagent');
195
+ } else if (tool === 'WebSearch' && input.query) {
196
+ parts.push(`"${input.query.slice(0, 60)}"`);
197
+ } else if (tool === 'WebFetch' && input.url) {
198
+ parts.push(`<span class="filepath">${input.url.slice(0, 60)}</span>`);
199
+ } else if (ev.message) {
200
+ parts.push(ev.message.slice(0, 80));
201
+ } else if (input.file_path) {
202
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
203
+ } else if (input.command) {
204
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 60)}</span>`);
205
+ } else if (ev.source_app) {
206
+ parts.push(ev.source_app);
207
+ }
208
+
209
+ return parts.join(' ');
134
210
  }
135
211
 
136
212
  function renderEvent(ev) {
137
213
  const sid = ev.session_id || ev.source_app || 'unknown';
138
214
  const color = sessionColor(sid);
215
+ const type = ev.event_type || ev.hook_event_name || '?';
216
+ const tool = ev.tool_name || '';
217
+ const human = needsHuman(ev);
218
+ const fail = isFailure(ev);
219
+ const boundary = isSessionBoundary(ev);
220
+
139
221
  const div = document.createElement('div');
140
- div.className = 'event';
222
+ let cls = 'event';
223
+ if (human) cls += ' needs-human';
224
+ else if (fail) cls += ' failure';
225
+ else if (boundary) cls += ' session-boundary';
226
+ div.className = cls;
227
+
141
228
  div.innerHTML = `
142
229
  <span class="time">${formatTime(ev._ts)}</span>
143
- <span class="type type-${ev.event_type || ev.hook_event_name || ''}">${ev.event_type || ev.hook_event_name || '?'}</span>
230
+ <span class="type type-${type}">${type}</span>
144
231
  <span class="session" style="background:${color}22;color:${color}">${shortSession(sid)}</span>
145
- <span class="detail" title="${eventDetail(ev)}">${eventDetail(ev)}</span>
146
- <span class="duration">${ev.duration_ms ? ev.duration_ms + 'ms' : ''}</span>
232
+ <span class="tool">${tool}</span>
233
+ <span class="detail">${eventDetail(ev)}</span>
147
234
  `;
148
235
 
149
236
  // Check filters
150
- const typeMatch = !selectedType || (ev.event_type || ev.hook_event_name) === selectedType;
237
+ const typeMatch = !selectedType || type === selectedType;
151
238
  const sessMatch = !selectedSession || sid === selectedSession;
152
- if (!typeMatch || !sessMatch) div.style.display = 'none';
239
+ const humanMatch = !humanOnly || human;
240
+ if (!typeMatch || !sessMatch || !humanMatch) div.style.display = 'none';
153
241
 
154
242
  return div;
155
243
  }
@@ -167,7 +255,12 @@ function updateStats() {
167
255
  document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
168
256
  document.getElementById('eventCount').textContent = events.length;
169
257
  document.getElementById('toolCount').textContent = events.filter(e => (e.event_type || e.hook_event_name || '').includes('ToolUse')).length;
170
- document.getElementById('errorCount').textContent = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
258
+ const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
259
+ document.getElementById('errorCount').textContent = errors;
260
+ document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
261
+ const humans = events.filter(needsHuman).length;
262
+ document.getElementById('humanCount').textContent = humans;
263
+ document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
171
264
  }
172
265
 
173
266
  function updateSessionBar() {
@@ -211,7 +304,9 @@ function refilter() {
211
304
  const ev = events[i];
212
305
  const sid = ev.session_id || ev.source_app || 'unknown';
213
306
  const type = ev.event_type || ev.hook_event_name || '';
214
- const show = (!selectedType || type === selectedType) && (!selectedSession || sid === selectedSession);
307
+ const show = (!selectedType || type === selectedType)
308
+ && (!selectedSession || sid === selectedSession)
309
+ && (!humanOnly || needsHuman(ev));
215
310
  row.style.display = show ? '' : 'none';
216
311
  });
217
312
  }
@@ -224,8 +319,13 @@ function toggleAutoScroll() {
224
319
  autoScroll = !autoScroll;
225
320
  document.getElementById('btnAutoScroll').classList.toggle('active', autoScroll);
226
321
  }
322
+ function toggleHumanOnly() {
323
+ humanOnly = !humanOnly;
324
+ document.getElementById('btnHumanOnly').classList.toggle('active', humanOnly);
325
+ refilter();
326
+ }
227
327
 
228
- // Server-Sent Events connection (replaces WebSocket — simpler, auto-reconnects)
328
+ // Server-Sent Events connection
229
329
  function connect() {
230
330
  const source = new EventSource('/stream');
231
331
 
@@ -237,7 +337,6 @@ function connect() {
237
337
  source.onerror = () => {
238
338
  statusDot.className = 'dot off';
239
339
  statusText.textContent = 'Reconnecting...';
240
- // EventSource auto-reconnects — no manual retry needed
241
340
  };
242
341
 
243
342
  source.onmessage = (msg) => {
@@ -248,9 +347,7 @@ function connect() {
248
347
  } else {
249
348
  addEvent(data);
250
349
  }
251
- } catch (e) {
252
- // ignore parse errors (ping frames, etc.)
253
- }
350
+ } catch (e) {}
254
351
  };
255
352
  }
256
353