uv-suite 0.14.0 → 0.17.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
@@ -29,6 +29,10 @@ function usage() {
29
29
  uvs install --persona sport
30
30
  uvs info Show what's installed
31
31
 
32
+ Monitoring:
33
+ uvs watch Start Watchtower dashboard (open browser)
34
+ uvs watch --bg Start Watchtower in background
35
+
32
36
  Personas:
33
37
  spike Research & docs (Opus, max effort)
34
38
  sport New projects (Sonnet, high effort)
@@ -108,6 +112,42 @@ function launchCodex(persona, extra) {
108
112
  child.on('exit', (code) => process.exit(code || 0));
109
113
  }
110
114
 
115
+ function watch() {
116
+ const serverScript = path.join(UV_SUITE_DIR, 'watchtower', 'server.js');
117
+ if (!fs.existsSync(serverScript)) {
118
+ console.error('Error: watchtower server not found at', serverScript);
119
+ process.exit(1);
120
+ }
121
+
122
+ const bg = args.includes('--bg') || args.includes('--background');
123
+ console.log('UV Suite Watchtower starting...');
124
+ console.log('Dashboard: http://localhost:' + (process.env.UVS_WATCHTOWER_PORT || 4200));
125
+ console.log('');
126
+
127
+ if (bg) {
128
+ const child = spawn('node', [serverScript], {
129
+ stdio: 'ignore',
130
+ detached: true,
131
+ });
132
+ child.unref();
133
+ console.log(`Running in background (PID: ${child.pid})`);
134
+ console.log('Stop with: kill ' + child.pid);
135
+
136
+ // Open browser
137
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
138
+ spawn(opener, ['http://localhost:' + (process.env.UVS_WATCHTOWER_PORT || 4200)], { stdio: 'ignore' });
139
+ } else {
140
+ // Foreground — open browser after a short delay
141
+ setTimeout(() => {
142
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
143
+ spawn(opener, ['http://localhost:' + (process.env.UVS_WATCHTOWER_PORT || 4200)], { stdio: 'ignore' });
144
+ }, 1000);
145
+
146
+ const child = spawn('node', [serverScript], { stdio: 'inherit' });
147
+ child.on('exit', (code) => process.exit(code || 0));
148
+ }
149
+ }
150
+
111
151
  // --- Parse and route ---
112
152
 
113
153
  if (!command || command === '--help' || command === '-h') {
@@ -115,7 +155,9 @@ if (!command || command === '--help' || command === '-h') {
115
155
  process.exit(0);
116
156
  }
117
157
 
118
- if (command === 'install') {
158
+ if (command === 'watch') {
159
+ watch();
160
+ } else if (command === 'install') {
119
161
  install();
120
162
  } else if (command === 'info') {
121
163
  info();
@@ -15,4 +15,8 @@ if [ ! -f "$TODAY_FILE" ]; then
15
15
  echo "0" > "$TODAY_FILE"
16
16
  fi
17
17
 
18
+ # Send to watchtower
19
+ echo '{"session_id":"'$(cat "$STATE_DIR/session-start.txt")'","cwd":"'${CLAUDE_PROJECT_DIR:-.}'"}' | \
20
+ "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/watchtower-send.sh" "SessionStart" 2>/dev/null
21
+
18
22
  exit 0
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # UV Suite Hook Helper: Send event to Watchtower server
3
+ # Called by other hooks. Non-blocking. Fails silently if server not running.
4
+ #
5
+ # Usage: echo "$INPUT" | .claude/hooks/watchtower-send.sh "EventType"
6
+
7
+ EVENT_TYPE="${1:-Unknown}"
8
+ INPUT=$(cat)
9
+ WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
10
+
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)
17
+
18
+ # Send to watchtower (non-blocking, fire-and-forget)
19
+ curl -s -X POST "$WATCHTOWER_URL/events" \
20
+ -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\"}" \
22
+ &>/dev/null &
23
+
24
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.14.0",
3
+ "version": "0.17.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",
@@ -37,6 +37,7 @@
37
37
  "portable-standards/",
38
38
  "settings.json",
39
39
  "uv.sh",
40
+ "watchtower/",
40
41
  "install.sh",
41
42
  "README.md"
42
43
  ]
@@ -75,7 +75,8 @@
75
75
  {
76
76
  "matcher": "*",
77
77
  "hooks": [
78
- { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-timer.sh", "timeout": 5 }
78
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-timer.sh", "timeout": 5 },
79
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse", "timeout": 2, "async": true }
79
80
  ]
80
81
  },
81
82
  {
@@ -92,6 +92,12 @@
92
92
  "type": "command",
93
93
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-timer.sh",
94
94
  "timeout": 5
95
+ },
96
+ {
97
+ "type": "command",
98
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
99
+ "timeout": 2,
100
+ "async": true
95
101
  }
96
102
  ]
97
103
  },
@@ -45,6 +45,12 @@
45
45
  }
46
46
  ],
47
47
  "PostToolUse": [
48
+ {
49
+ "matcher": "*",
50
+ "hooks": [
51
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse", "timeout": 2, "async": true }
52
+ ]
53
+ },
48
54
  {
49
55
  "matcher": "Write",
50
56
  "hooks": [
@@ -31,6 +31,12 @@
31
31
  }
32
32
  ],
33
33
  "PostToolUse": [
34
+ {
35
+ "matcher": "*",
36
+ "hooks": [
37
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse", "timeout": 2, "async": true }
38
+ ]
39
+ },
34
40
  {
35
41
  "matcher": "Edit|Write",
36
42
  "hooks": [
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: commit
3
+ description: >
4
+ Review, test, slop-check, then commit and optionally open a PR.
5
+ The ship pipeline: one command from "code complete" to "committed and reviewed."
6
+ argument-hint: "[commit-message or 'pr']"
7
+ user-invocable: true
8
+ allowed-tools:
9
+ - Read(*)
10
+ - Grep(*)
11
+ - Glob(*)
12
+ - Write(*)
13
+ - Bash(git *)
14
+ - Bash(npm test *)
15
+ - Bash(npm run test *)
16
+ - Bash(npm run lint *)
17
+ - Bash(npx jest *)
18
+ - Bash(npx vitest *)
19
+ - Bash(pytest *)
20
+ - Bash(go test *)
21
+ - Bash(cargo test *)
22
+ - Bash(gh pr *)
23
+ ---
24
+
25
+ ## Intent
26
+
27
+ $ARGUMENTS
28
+
29
+ ## Current state
30
+
31
+ !`git status --short 2>/dev/null | head -30 || echo "not a git repo"`
32
+
33
+ !`git diff --cached --stat 2>/dev/null || echo "nothing staged"`
34
+
35
+ !`git diff --stat 2>/dev/null || echo "no unstaged changes"`
36
+
37
+ ## Pipeline — run these steps in order
38
+
39
+ ### 1. Run tests
40
+
41
+ Find and run the project's test command. If tests fail, fix the failures before continuing. If they can't be fixed in 2 attempts, stop and report.
42
+
43
+ ### 2. Run lint
44
+
45
+ If a lint command exists (npm run lint, ruff check, etc.), run it. Fix auto-fixable issues.
46
+
47
+ ### 3. Quick slop check
48
+
49
+ Scan changed files for the most obvious patterns:
50
+ - `toBeTruthy()` / `toBeDefined()` in test files
51
+ - Bare `except: pass` in Python
52
+
53
+ Don't run the full /slop-check agent — just grep for mechanical patterns.
54
+
55
+ ### 4. Review the diff
56
+
57
+ Read the full diff. Check for:
58
+ - Correctness: does it do what it should?
59
+ - Security: any obvious issues? (hardcoded secrets, missing auth checks)
60
+ - Completeness: anything half-done or TODO'd?
61
+
62
+ If you find issues, fix them. If they need human input, stop and ask.
63
+
64
+ ### 5. Stage and commit
65
+
66
+ - Stage the relevant files (not `.env`, not `node_modules`, not build artifacts)
67
+ - Write a commit message. If the user provided one in $ARGUMENTS, use it. Otherwise, write a concise message that describes the WHY, not the WHAT.
68
+ - Commit.
69
+
70
+ ### 6. Open PR (if requested)
71
+
72
+ If the user said "pr" in their arguments, or if on a feature branch:
73
+ - Push the branch
74
+ - Open a PR with `gh pr create`
75
+ - Title: short, under 70 chars
76
+ - Body: summary bullets + test plan
77
+
78
+ ### 7. Checkpoint
79
+
80
+ After committing, write a checkpoint to `uv-out/checkpoints/latest.md` with what was committed.
81
+
82
+ ## Danger zones
83
+
84
+ !`cat DANGER-ZONES.md 2>/dev/null || echo "No DANGER-ZONES.md"`
85
+
86
+ ## Rules
87
+
88
+ - If tests fail and can't be fixed quickly, STOP. Don't commit broken code.
89
+ - If the diff touches a danger zone file, flag it before committing.
90
+ - Prefer separate commits per logical change if there are unrelated changes staged.
91
+ - Never commit .env, credentials, or secrets.
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: investigate
3
+ description: >
4
+ Systematic root-cause debugging. Traces data flow, tests hypotheses,
5
+ narrows scope. Stops after 3 failed attempts and escalates.
6
+ Use when something is broken and you don't know why.
7
+ argument-hint: "[bug-description or error-message]"
8
+ user-invocable: true
9
+ context: fork
10
+ agent: reviewer
11
+ model: claude-opus-4-6
12
+ effort: max
13
+ allowed-tools:
14
+ - Read(*)
15
+ - Grep(*)
16
+ - Glob(*)
17
+ - Bash(git log *)
18
+ - Bash(git diff *)
19
+ - Bash(git blame *)
20
+ - Bash(git show *)
21
+ - Bash(npm test *)
22
+ - Bash(npm run *)
23
+ - Bash(npx *)
24
+ - Bash(pytest *)
25
+ - Bash(go test *)
26
+ - Bash(cargo test *)
27
+ - Bash(curl *)
28
+ - Bash(node *)
29
+ - Bash(python *)
30
+ ---
31
+
32
+ ## The bug
33
+
34
+ $ARGUMENTS
35
+
36
+ ## Project context
37
+
38
+ !`cat CLAUDE.md 2>/dev/null || echo "No CLAUDE.md"`
39
+
40
+ ## Codebase map
41
+
42
+ !`cat uv-out/map-codebase.md 2>/dev/null | head -60 || echo "No codebase map"`
43
+
44
+ ## Recent changes (potential cause)
45
+
46
+ !`git log --oneline -15 2>/dev/null || echo "no git"`
47
+
48
+ ## Latest checkpoint
49
+
50
+ !`cat uv-out/checkpoints/latest.md 2>/dev/null | head -30 || echo "No checkpoint"`
51
+
52
+ ## Investigation methodology
53
+
54
+ Follow this process strictly:
55
+
56
+ ### Phase 1: Reproduce
57
+
58
+ Before investigating, reproduce the bug. Run the failing test, hit the failing endpoint, trigger the error. If you can't reproduce it, say so and ask for reproduction steps.
59
+
60
+ ### Phase 2: Narrow scope
61
+
62
+ Form a hypothesis about WHERE the bug is:
63
+ 1. Read the error message/stack trace carefully
64
+ 2. Identify the failing component (which file, which function, which layer)
65
+ 3. Check recent changes to that component (`git log --oneline [file]`)
66
+ 4. Check if the component was modified in the last 5 commits (`git diff HEAD~5 [file]`)
67
+
68
+ State your hypothesis explicitly: "I think the bug is in [X] because [Y]."
69
+
70
+ ### Phase 3: Test the hypothesis
71
+
72
+ Verify your hypothesis with the smallest possible test:
73
+ - Add a log/print statement
74
+ - Write a focused test case
75
+ - Run a specific command that isolates the behavior
76
+
77
+ If the hypothesis is wrong, form a new one. Track what you've ruled out.
78
+
79
+ ### Phase 4: Fix or escalate
80
+
81
+ If you found the root cause within 3 attempts: fix it, run the tests, verify.
82
+
83
+ If you haven't found it after 3 attempts:
84
+
85
+ ```
86
+ ## Stuck: [bug description]
87
+
88
+ Ruled out:
89
+ 1. [Hypothesis 1] — wrong because [evidence]
90
+ 2. [Hypothesis 2] — wrong because [evidence]
91
+ 3. [Hypothesis 3] — wrong because [evidence]
92
+
93
+ Remaining possibilities:
94
+ - [What I haven't checked yet]
95
+
96
+ What I need:
97
+ - [Specific question or access needed from the human]
98
+ ```
99
+
100
+ ## Rules
101
+
102
+ - NO FIXES WITHOUT INVESTIGATION. Don't guess and patch. Find the root cause first.
103
+ - State hypotheses explicitly before testing them.
104
+ - Track what you've ruled out so you don't revisit.
105
+ - 3 attempts max. Then escalate with structured findings.
106
+ - If the bug is in code you don't understand, run /map-codebase on that area first.
107
+
108
+ ## Artifact output
109
+
110
+ Write investigation findings to `uv-out/investigate-YYYY-MM-DD.md`. Include: bug description, hypotheses tested, root cause found (or not), fix applied (or escalation).
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: uv-help
3
+ description: >
4
+ Show all UV Suite skills, agents, hooks, guardrails, and personas.
5
+ Use when you want to know what's available or how to use a specific feature.
6
+ argument-hint: "[skill-name or topic]"
7
+ user-invocable: true
8
+ allowed-tools:
9
+ - Read(*)
10
+ - Glob(*)
11
+ ---
12
+
13
+ ## UV Suite Help
14
+
15
+ $ARGUMENTS
16
+
17
+ If the user asked about a specific skill or topic, focus on that. Otherwise, show the full overview below.
18
+
19
+ ## Active persona
20
+
21
+ !`grep "Active persona" CLAUDE.md 2>/dev/null || echo "Unknown — check .claude/settings.json"`
22
+
23
+ ## All available skills
24
+
25
+ Every skill accepts free-form arguments to direct the agent. Examples shown below.
26
+
27
+ ### Understand
28
+
29
+ | Skill | What it does | Example |
30
+ |-------|-------------|---------|
31
+ | `/map-codebase [focus]` | Build a knowledge graph of the codebase | `/map-codebase focus on the auth flow and session management` |
32
+ | `/map-stack [dir]` | Map multiple services and their connections | `/map-stack show how layer3-max calls layer2-pie` |
33
+
34
+ ### Plan
35
+
36
+ | Skill | What it does | Example |
37
+ |-------|-------------|---------|
38
+ | `/spec [requirements]` | Write a technical specification | `/spec webhook retry with exponential backoff, max 3 retries` |
39
+ | `/architect [spec]` | Design architecture, decompose into Acts | `/architect design for horizontal scaling, expect 10x traffic` |
40
+
41
+ ### Build
42
+
43
+ | Skill | What it does | Example |
44
+ |-------|-------------|---------|
45
+ | `/write-tests [target]` | Generate tests matching project conventions | `/write-tests src/auth/login.ts focus on error paths` |
46
+ | `/write-evals [prompt]` | Write AI/LLM evaluation cases | `/write-evals test the search ranking prompt for adversarial inputs` |
47
+ | `/prototype [concept]` | Build a static React prototype | `/prototype event booking app with calendar and payment flow` |
48
+
49
+ ### Review
50
+
51
+ | Skill | What it does | Example |
52
+ |-------|-------------|---------|
53
+ | `/review [focus]` | Code review: correctness, security, perf, slop | `/review pay attention to the new database migration` |
54
+ | `/slop-check [target]` | Detect 6 categories of AI-generated slop | `/slop-check src/components/ check for over-engineering` |
55
+ | `/security-review [target]` | OWASP audit, dependency scan, secret detection | `/security-review src/payments/ focus on webhook signature validation` |
56
+
57
+ ### Ship
58
+
59
+ | Skill | What it does | Example |
60
+ |-------|-------------|---------|
61
+ | `/commit [message]` | Test, lint, review, commit (optionally open PR) | `/commit "Add webhook retry logic" pr` |
62
+ | `/investigate [bug]` | Root-cause debugging (3 attempts then escalate) | `/investigate search returns stale results after reindex` |
63
+
64
+ ### Session
65
+
66
+ | Skill | What it does | Example |
67
+ |-------|-------------|---------|
68
+ | `/checkpoint [label]` | Save session state for next time | `/checkpoint auth-refactor` |
69
+ | `/restore` | Load latest checkpoint at session start | `/restore` |
70
+
71
+ ## Agents (spawned by skills)
72
+
73
+ | Agent | Model | Used by |
74
+ |-------|-------|---------|
75
+ | Cartographer | Opus | /map-codebase, /map-stack |
76
+ | Spec Writer | Opus | /spec |
77
+ | Architect | Opus | /architect |
78
+ | Reviewer | Opus | /review, /investigate |
79
+ | Test Writer | Sonnet | /write-tests |
80
+ | Eval Writer | Opus | /write-evals |
81
+ | Anti-Slop Guard | Opus | /slop-check |
82
+ | Prototype Builder | Sonnet | /prototype |
83
+ | DevOps | Opus | (direct invocation) |
84
+ | Security | Opus | /security-review |
85
+
86
+ ## Hooks (automatic, you don't invoke these)
87
+
88
+ !`ls .claude/hooks/*.sh 2>/dev/null | while read f; do echo "- $(basename $f)"; done || echo "No hooks installed"`
89
+
90
+ ## Guardrails (anti-slop rules, active as context)
91
+
92
+ !`ls .claude/rules/*.md 2>/dev/null | while read f; do echo "- $(basename $f .md)"; done || echo "No guardrails installed"`
93
+
94
+ ## Personas
95
+
96
+ | Persona | Launch | For |
97
+ |---------|--------|-----|
98
+ | Spike | `uvs spike` | Research, docs, architecture analysis |
99
+ | Sport | `uvs sport` | New projects, fast prototyping |
100
+ | Professional | `uvs pro` | Production code, full review rigor |
101
+ | Auto | `uvs auto` | Autonomous execution, clear specs |
102
+
103
+ ## Artifacts
104
+
105
+ All agent output goes to `uv-out/`. Agents read each other's prior output automatically.
106
+
107
+ !`ls uv-out/*.md uv-out/**/*.md 2>/dev/null | head -15 || echo "No artifacts yet — run a skill to generate some"`
108
+
109
+ ## Tips
110
+
111
+ - **Direct the agent:** Every skill accepts arguments. "/review" does a generic review. "/review focus on the error handling in the retry logic" gives targeted results.
112
+ - **Run in parallel:** "Run /review, /slop-check, and /security-review in parallel" — Claude spawns all three simultaneously.
113
+ - **Checkpoint before stopping:** "/checkpoint" saves your session state. "/restore" loads it next time.
114
+ - **Use the right persona:** `uvs spike` for research, `uvs pro` for production code, `uvs auto` to let it run.
@@ -0,0 +1,257 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>UV Suite Watchtower</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; background: #000; color: #f5f5f7; }
10
+ .header { padding: 16px 24px; border-bottom: 1px solid #2d2d2f; display: flex; align-items: center; justify-content: space-between; }
11
+ .header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
12
+ .header .status { font-size: 11px; color: #86868b; }
13
+ .header .status .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
14
+ .header .status .dot.on { background: #30d158; }
15
+ .header .status .dot.off { background: #ff453a; }
16
+
17
+ .filters { padding: 8px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
18
+ .filters select, .filters button { background: #1d1d1f; color: #a1a1a6; border: 1px solid #2d2d2f; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; }
19
+ .filters select:focus, .filters button:hover { border-color: #424245; color: #f5f5f7; }
20
+ .filters button.active { background: #0071e3; color: #fff; border-color: #0071e3; }
21
+
22
+ .stats { padding: 12px 24px; display: flex; gap: 24px; border-bottom: 1px solid #1d1d1f; }
23
+ .stat { text-align: center; }
24
+ .stat .n { font-size: 20px; font-weight: 600; }
25
+ .stat .l { font-size: 10px; color: #86868b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
26
+
27
+ .sessions { padding: 12px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
28
+ .session-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
29
+ .session-tag:hover { border-color: #424245; }
30
+ .session-tag.active { border-color: #fff; }
31
+
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; }
34
+ .event:hover { background: #0d0d0d; }
35
+ .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
36
+ .event .type { font-weight: 500; }
37
+ .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; }
40
+
41
+ .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
42
+ .empty p { margin-top: 8px; font-size: 13px; }
43
+
44
+ /* Event type colors */
45
+ .type-SessionStart { color: #30d158; }
46
+ .type-SessionEnd, .type-Stop { color: #ff453a; }
47
+ .type-PreToolUse { color: #0a84ff; }
48
+ .type-PostToolUse { color: #64d2ff; }
49
+ .type-PostToolUseFailure { color: #ff6961; }
50
+ .type-UserPromptSubmit { color: #ffd60a; }
51
+ .type-SubagentStart { color: #bf5af2; }
52
+ .type-SubagentStop { color: #ac8ee0; }
53
+ .type-Notification { color: #ff9f0a; }
54
+ .type-PermissionRequest { color: #ff375f; }
55
+ .type-PreCompact { color: #6e6e73; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+
60
+ <div class="header">
61
+ <h1>UV Suite Watchtower</h1>
62
+ <div class="status"><span class="dot" id="statusDot"></span><span id="statusText">Connecting...</span></div>
63
+ </div>
64
+
65
+ <div class="stats">
66
+ <div class="stat"><div class="n" id="sessionCount">0</div><div class="l">Sessions</div></div>
67
+ <div class="stat"><div class="n" id="eventCount">0</div><div class="l">Events</div></div>
68
+ <div class="stat"><div class="n" id="toolCount">0</div><div class="l">Tool calls</div></div>
69
+ <div class="stat"><div class="n" id="errorCount">0</div><div class="l">Errors</div></div>
70
+ </div>
71
+
72
+ <div class="sessions" id="sessionBar"></div>
73
+
74
+ <div class="filters">
75
+ <select id="filterType"><option value="">All events</option></select>
76
+ <select id="filterSession"><option value="">All sessions</option></select>
77
+ <button id="btnClear" onclick="clearEvents()">Clear</button>
78
+ <button id="btnAutoScroll" class="active" onclick="toggleAutoScroll()">Auto-scroll</button>
79
+ </div>
80
+
81
+ <div class="timeline" id="timeline">
82
+ <div class="empty" id="emptyState">
83
+ <p><strong>Waiting for events</strong></p>
84
+ <p>Start a Claude Code session with UV Suite hooks.<br>Events will appear here in real-time.</p>
85
+ </div>
86
+ </div>
87
+
88
+ <script>
89
+ const timeline = document.getElementById('timeline');
90
+ const emptyState = document.getElementById('emptyState');
91
+ const filterType = document.getElementById('filterType');
92
+ const filterSession = document.getElementById('filterSession');
93
+ const statusDot = document.getElementById('statusDot');
94
+ const statusText = document.getElementById('statusText');
95
+ const sessionBar = document.getElementById('sessionBar');
96
+
97
+ let events = [];
98
+ let sessions = {};
99
+ let autoScroll = true;
100
+ let selectedSession = '';
101
+ let selectedType = '';
102
+ let ws;
103
+
104
+ // Session colors
105
+ const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
106
+ let colorIdx = 0;
107
+ function sessionColor(id) {
108
+ if (!sessions[id]) {
109
+ sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null };
110
+ updateSessionBar();
111
+ updateFilterSession();
112
+ }
113
+ sessions[id].count++;
114
+ sessions[id].lastEvent = Date.now();
115
+ return sessions[id].color;
116
+ }
117
+
118
+ function formatTime(ts) {
119
+ const d = new Date(ts);
120
+ return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
121
+ }
122
+
123
+ function shortSession(id) {
124
+ if (!id) return '—';
125
+ return id.length > 12 ? id.slice(0, 8) + '...' : id;
126
+ }
127
+
128
+ 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 '';
134
+ }
135
+
136
+ function renderEvent(ev) {
137
+ const sid = ev.session_id || ev.source_app || 'unknown';
138
+ const color = sessionColor(sid);
139
+ const div = document.createElement('div');
140
+ div.className = 'event';
141
+ div.innerHTML = `
142
+ <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>
144
+ <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>
147
+ `;
148
+
149
+ // Check filters
150
+ const typeMatch = !selectedType || (ev.event_type || ev.hook_event_name) === selectedType;
151
+ const sessMatch = !selectedSession || sid === selectedSession;
152
+ if (!typeMatch || !sessMatch) div.style.display = 'none';
153
+
154
+ return div;
155
+ }
156
+
157
+ function addEvent(ev) {
158
+ events.push(ev);
159
+ if (emptyState.parentNode) emptyState.remove();
160
+ timeline.appendChild(renderEvent(ev));
161
+ updateStats();
162
+ updateFilterType(ev);
163
+ if (autoScroll) timeline.scrollTop = timeline.scrollHeight;
164
+ }
165
+
166
+ function updateStats() {
167
+ document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
168
+ document.getElementById('eventCount').textContent = events.length;
169
+ 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;
171
+ }
172
+
173
+ function updateSessionBar() {
174
+ sessionBar.innerHTML = '';
175
+ for (const [id, s] of Object.entries(sessions)) {
176
+ const tag = document.createElement('span');
177
+ tag.className = 'session-tag' + (selectedSession === id ? ' active' : '');
178
+ tag.style.background = s.color + '22';
179
+ tag.style.color = s.color;
180
+ tag.textContent = shortSession(id) + ' (' + s.count + ')';
181
+ tag.onclick = () => { selectedSession = selectedSession === id ? '' : id; refilter(); updateSessionBar(); };
182
+ sessionBar.appendChild(tag);
183
+ }
184
+ }
185
+
186
+ const knownTypes = new Set();
187
+ function updateFilterType(ev) {
188
+ const t = ev.event_type || ev.hook_event_name;
189
+ if (t && !knownTypes.has(t)) {
190
+ knownTypes.add(t);
191
+ const opt = document.createElement('option');
192
+ opt.value = t; opt.textContent = t;
193
+ filterType.appendChild(opt);
194
+ }
195
+ }
196
+
197
+ function updateFilterSession() {
198
+ filterSession.innerHTML = '<option value="">All sessions</option>';
199
+ for (const id of Object.keys(sessions)) {
200
+ const opt = document.createElement('option');
201
+ opt.value = id; opt.textContent = shortSession(id);
202
+ filterSession.appendChild(opt);
203
+ }
204
+ }
205
+
206
+ function refilter() {
207
+ selectedType = filterType.value;
208
+ selectedSession = filterSession.value;
209
+ const rows = timeline.querySelectorAll('.event');
210
+ rows.forEach((row, i) => {
211
+ const ev = events[i];
212
+ const sid = ev.session_id || ev.source_app || 'unknown';
213
+ const type = ev.event_type || ev.hook_event_name || '';
214
+ const show = (!selectedType || type === selectedType) && (!selectedSession || sid === selectedSession);
215
+ row.style.display = show ? '' : 'none';
216
+ });
217
+ }
218
+
219
+ filterType.onchange = refilter;
220
+ filterSession.onchange = refilter;
221
+
222
+ function clearEvents() { events = []; sessions = {}; colorIdx = 0; timeline.innerHTML = ''; updateStats(); sessionBar.innerHTML = ''; }
223
+ function toggleAutoScroll() {
224
+ autoScroll = !autoScroll;
225
+ document.getElementById('btnAutoScroll').classList.toggle('active', autoScroll);
226
+ }
227
+
228
+ // WebSocket connection
229
+ function connect() {
230
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
231
+ ws = new WebSocket(`${proto}//${location.host}/ws`);
232
+
233
+ ws.onopen = () => {
234
+ statusDot.className = 'dot on';
235
+ statusText.textContent = 'Connected';
236
+ };
237
+
238
+ ws.onclose = () => {
239
+ statusDot.className = 'dot off';
240
+ statusText.textContent = 'Disconnected — reconnecting...';
241
+ setTimeout(connect, 2000);
242
+ };
243
+
244
+ ws.onmessage = (msg) => {
245
+ const data = JSON.parse(msg.data);
246
+ if (data.type === 'init' && data.events) {
247
+ data.events.forEach(addEvent);
248
+ } else {
249
+ addEvent(data);
250
+ }
251
+ };
252
+ }
253
+
254
+ connect();
255
+ </script>
256
+ </body>
257
+ </html>
@@ -0,0 +1,11 @@
1
+ [
2
+ {
3
+ "event_type": "PostToolUse",
4
+ "session_id": "test-123",
5
+ "source_app": "uv-suite",
6
+ "tool_name": "Edit",
7
+ "cwd": "/tmp",
8
+ "_ts": 1776756371726,
9
+ "_id": "3f130976-6226-47ea-a477-18a16e194415"
10
+ }
11
+ ]
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ // UV Suite Watchtower — lightweight observability server
4
+ // Zero dependencies beyond Node.js (uses built-in http, fs, ws via raw upgrade)
5
+
6
+ const http = require('http');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
11
+ const DATA_FILE = path.join(__dirname, 'events.json');
12
+ const MAX_EVENTS = 500;
13
+
14
+ // In-memory event store
15
+ let events = [];
16
+ try {
17
+ if (fs.existsSync(DATA_FILE)) {
18
+ events = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
19
+ }
20
+ } catch (e) {
21
+ events = [];
22
+ }
23
+
24
+ // WebSocket clients
25
+ const clients = new Set();
26
+
27
+ function broadcast(event) {
28
+ const msg = JSON.stringify(event);
29
+ for (const ws of clients) {
30
+ try { ws.send(msg); } catch (e) { clients.delete(ws); }
31
+ }
32
+ }
33
+
34
+ function saveEvents() {
35
+ // Keep only the last MAX_EVENTS
36
+ if (events.length > MAX_EVENTS) {
37
+ events = events.slice(-MAX_EVENTS);
38
+ }
39
+ fs.writeFileSync(DATA_FILE, JSON.stringify(events, null, 2));
40
+ }
41
+
42
+ // Minimal WebSocket handshake (no dependency needed)
43
+ const crypto = require('crypto');
44
+
45
+ function upgradeToWebSocket(req, socket) {
46
+ const key = req.headers['sec-websocket-key'];
47
+ const accept = crypto.createHash('sha1')
48
+ .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC085B11')
49
+ .digest('base64');
50
+
51
+ socket.write(
52
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
53
+ 'Upgrade: websocket\r\n' +
54
+ 'Connection: Upgrade\r\n' +
55
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
56
+ '\r\n'
57
+ );
58
+
59
+ const ws = {
60
+ send(data) {
61
+ const buf = Buffer.from(data);
62
+ const frame = [];
63
+ frame.push(0x81); // text frame
64
+ if (buf.length < 126) {
65
+ frame.push(buf.length);
66
+ } else if (buf.length < 65536) {
67
+ frame.push(126, (buf.length >> 8) & 0xff, buf.length & 0xff);
68
+ } else {
69
+ frame.push(127);
70
+ for (let i = 7; i >= 0; i--) frame.push((buf.length >> (i * 8)) & 0xff);
71
+ }
72
+ socket.write(Buffer.concat([Buffer.from(frame), buf]));
73
+ }
74
+ };
75
+
76
+ clients.add(ws);
77
+ socket.on('close', () => clients.delete(ws));
78
+ socket.on('error', () => clients.delete(ws));
79
+
80
+ // Send recent events on connect
81
+ ws.send(JSON.stringify({ type: 'init', events: events.slice(-100) }));
82
+ }
83
+
84
+ const server = http.createServer((req, res) => {
85
+ // CORS
86
+ res.setHeader('Access-Control-Allow-Origin', '*');
87
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
88
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
89
+
90
+ if (req.method === 'OPTIONS') {
91
+ res.writeHead(200);
92
+ return res.end();
93
+ }
94
+
95
+ // POST /events — receive hook events
96
+ if (req.method === 'POST' && req.url === '/events') {
97
+ let body = '';
98
+ req.on('data', chunk => body += chunk);
99
+ req.on('end', () => {
100
+ try {
101
+ const event = JSON.parse(body);
102
+ event._ts = Date.now();
103
+ event._id = crypto.randomUUID();
104
+ events.push(event);
105
+ broadcast(event);
106
+ saveEvents();
107
+ res.writeHead(200, { 'Content-Type': 'application/json' });
108
+ res.end('{"ok":true}');
109
+ } catch (e) {
110
+ res.writeHead(400);
111
+ res.end('{"error":"invalid json"}');
112
+ }
113
+ });
114
+ return;
115
+ }
116
+
117
+ // GET /events — fetch recent events
118
+ if (req.method === 'GET' && req.url.startsWith('/events')) {
119
+ res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify(events.slice(-100)));
121
+ return;
122
+ }
123
+
124
+ // GET / — serve dashboard
125
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
126
+ const html = fs.readFileSync(path.join(__dirname, 'dashboard.html'), 'utf-8');
127
+ res.writeHead(200, { 'Content-Type': 'text/html' });
128
+ res.end(html);
129
+ return;
130
+ }
131
+
132
+ res.writeHead(404);
133
+ res.end('not found');
134
+ });
135
+
136
+ server.on('upgrade', (req, socket, head) => {
137
+ if (req.url === '/ws') {
138
+ upgradeToWebSocket(req, socket);
139
+ } else {
140
+ socket.destroy();
141
+ }
142
+ });
143
+
144
+ server.listen(PORT, () => {
145
+ console.log(`UV Suite Watchtower running at http://localhost:${PORT}`);
146
+ console.log(`${events.length} events loaded from disk`);
147
+ console.log(`Waiting for hook events on POST http://localhost:${PORT}/events`);
148
+ });