uv-suite 0.28.0 → 0.30.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -35
  3. package/agents/claude-code/anti-slop-guard.md +14 -1
  4. package/agents/claude-code/architect.md +30 -4
  5. package/agents/claude-code/cartographer.md +18 -6
  6. package/agents/claude-code/eval-writer.md +7 -2
  7. package/agents/claude-code/reviewer.md +5 -1
  8. package/agents/claude-code/spec-writer.md +30 -7
  9. package/agents/generate.py +88 -0
  10. package/bin/cli.js +51 -48
  11. package/hooks/auto-checkpoint-helper.sh +2 -2
  12. package/hooks/auto-checkpoint.sh +3 -3
  13. package/hooks/auto-restore-on-start.sh +30 -0
  14. package/hooks/checkpoint-helper.sh +40 -35
  15. package/hooks/git-context.sh +41 -0
  16. package/hooks/lite-mode-inject.sh +26 -0
  17. package/hooks/session-end-helper.sh +2 -2
  18. package/hooks/session-end.sh +2 -2
  19. package/hooks/session-label-nag.sh +2 -2
  20. package/hooks/session-meta.sh +18 -1
  21. package/hooks/session-review-reminder.sh +2 -2
  22. package/hooks/session-start.sh +16 -0
  23. package/hooks/slop-grep.sh +12 -31
  24. package/hooks/uv-out-best.sh +20 -0
  25. package/hooks/uv-out-collect.sh +52 -0
  26. package/hooks/uv-out-notify.sh +28 -0
  27. package/hooks/uv-out-pointer.sh +16 -0
  28. package/hooks/uv-out-session.sh +24 -0
  29. package/hooks/watchtower-notify.sh +45 -0
  30. package/hooks/watchtower-send.sh +4 -0
  31. package/install.sh +93 -42
  32. package/package.json +2 -2
  33. package/personas/auto.json +40 -1
  34. package/personas/professional.json +46 -1
  35. package/personas/spike.json +32 -2
  36. package/personas/sport.json +44 -1
  37. package/settings.json +6 -2
  38. package/skills/architect/SKILL.md +109 -8
  39. package/skills/architect/specialists/distributed-systems.md +84 -0
  40. package/skills/architect/specialists/full-stack.md +92 -0
  41. package/skills/architect/specialists/llm-ai-engineering.md +86 -0
  42. package/skills/architect/specialists/ml-systems.md +81 -0
  43. package/skills/commit/SKILL.md +5 -2
  44. package/skills/confirm/SKILL.md +3 -3
  45. package/skills/investigate/SKILL.md +14 -4
  46. package/skills/lite/SKILL.md +45 -0
  47. package/skills/qa/SKILL.md +274 -0
  48. package/skills/review/SKILL.md +187 -8
  49. package/skills/review/specialists/api-contract.md +122 -0
  50. package/skills/review/specialists/architecture-trace.md +64 -0
  51. package/skills/review/specialists/data-migration.md +113 -0
  52. package/skills/review/specialists/maintainability.md +138 -0
  53. package/skills/review/specialists/performance.md +115 -0
  54. package/skills/review/specialists/security.md +132 -0
  55. package/skills/review/specialists/testing.md +109 -0
  56. package/skills/session/SKILL.md +87 -0
  57. package/skills/session/operations/auto.md +22 -0
  58. package/skills/session/operations/checkpoint.md +43 -0
  59. package/skills/session/operations/end.md +35 -0
  60. package/skills/session/operations/init.md +16 -0
  61. package/skills/session/operations/restore.md +16 -0
  62. package/skills/spec/SKILL.md +40 -1
  63. package/skills/test/SKILL.md +89 -0
  64. package/skills/test/specialists/eval.md +46 -0
  65. package/skills/test/specialists/integration.md +42 -0
  66. package/skills/test/specialists/unit.md +39 -0
  67. package/skills/understand/SKILL.md +118 -0
  68. package/skills/understand/modes/repo.md +38 -0
  69. package/skills/understand/modes/stack.md +41 -0
  70. package/skills/uv-help/SKILL.md +43 -20
  71. package/uv.sh +36 -3
  72. package/watchtower/Dockerfile +9 -0
  73. package/watchtower/README.md +78 -0
  74. package/watchtower/app/__init__.py +0 -0
  75. package/watchtower/app/__pycache__/__init__.cpython-314.pyc +0 -0
  76. package/watchtower/app/__pycache__/db.cpython-314.pyc +0 -0
  77. package/watchtower/app/__pycache__/main.cpython-314.pyc +0 -0
  78. package/watchtower/app/__pycache__/models.cpython-314.pyc +0 -0
  79. package/watchtower/app/db.py +85 -0
  80. package/watchtower/app/main.py +45 -0
  81. package/watchtower/app/models.py +49 -0
  82. package/watchtower/app/routers/__init__.py +0 -0
  83. package/watchtower/app/routers/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/watchtower/app/routers/__pycache__/control.cpython-314.pyc +0 -0
  85. package/watchtower/app/routers/__pycache__/ingest.cpython-314.pyc +0 -0
  86. package/watchtower/app/routers/__pycache__/query.cpython-314.pyc +0 -0
  87. package/watchtower/app/routers/__pycache__/stream.cpython-314.pyc +0 -0
  88. package/watchtower/app/routers/control.py +144 -0
  89. package/watchtower/app/routers/ingest.py +102 -0
  90. package/watchtower/app/routers/query.py +84 -0
  91. package/watchtower/app/routers/stream.py +30 -0
  92. package/watchtower/app/services/__init__.py +0 -0
  93. package/watchtower/app/services/__pycache__/__init__.cpython-314.pyc +0 -0
  94. package/watchtower/app/services/__pycache__/checkpoint.cpython-314.pyc +0 -0
  95. package/watchtower/app/services/__pycache__/tmux.cpython-314.pyc +0 -0
  96. package/watchtower/app/services/checkpoint.py +107 -0
  97. package/watchtower/app/services/tmux.py +54 -0
  98. package/watchtower/docker-compose.yml +22 -0
  99. package/watchtower/events.json +10373 -1
  100. package/watchtower/{auto-checkpoint-runner.js → legacy/auto-checkpoint-runner.js} +29 -2
  101. package/watchtower/{dashboard.html → legacy/dashboard.html} +261 -0
  102. package/watchtower/{server.js → legacy/server.js} +63 -0
  103. package/watchtower/legacy/snapshot-manager.js +305 -0
  104. package/watchtower/requirements.txt +3 -0
  105. package/watchtower/schema.sql +43 -0
  106. package/watchtower/static/dashboard.html +449 -0
  107. package/agents/claude-code/devops.md +0 -50
  108. package/agents/claude-code/security.md +0 -75
  109. package/agents/codex/anti-slop-guard.toml +0 -12
  110. package/agents/codex/architect.toml +0 -11
  111. package/agents/codex/cartographer.toml +0 -16
  112. package/agents/codex/devops.toml +0 -8
  113. package/agents/codex/eval-writer.toml +0 -11
  114. package/agents/codex/prototype-builder.toml +0 -10
  115. package/agents/codex/reviewer.toml +0 -16
  116. package/agents/codex/security.toml +0 -14
  117. package/agents/codex/spec-writer.toml +0 -11
  118. package/agents/codex/test-writer.toml +0 -13
  119. package/agents/cursor/anti-slop-guard.mdc +0 -22
  120. package/agents/cursor/architect.mdc +0 -24
  121. package/agents/cursor/cartographer.mdc +0 -28
  122. package/agents/cursor/devops.mdc +0 -16
  123. package/agents/cursor/eval-writer.mdc +0 -21
  124. package/agents/cursor/prototype-builder.mdc +0 -25
  125. package/agents/cursor/reviewer.mdc +0 -26
  126. package/agents/cursor/security.mdc +0 -20
  127. package/agents/cursor/spec-writer.mdc +0 -27
  128. package/agents/cursor/test-writer.mdc +0 -28
  129. package/agents/portable/anti-slop-guard.md +0 -71
  130. package/agents/portable/architect.md +0 -83
  131. package/agents/portable/cartographer.md +0 -64
  132. package/agents/portable/devops.md +0 -56
  133. package/agents/portable/eval-writer.md +0 -70
  134. package/agents/portable/prototype-builder.md +0 -70
  135. package/agents/portable/reviewer.md +0 -79
  136. package/agents/portable/security.md +0 -63
  137. package/agents/portable/spec-writer.md +0 -89
  138. package/agents/portable/test-writer.md +0 -56
  139. package/hooks/context-warning.sh +0 -4
  140. package/skills/auto-checkpoint/SKILL.md +0 -47
  141. package/skills/checkpoint/SKILL.md +0 -105
  142. package/skills/map-codebase/SKILL.md +0 -54
  143. package/skills/map-stack/SKILL.md +0 -121
  144. package/skills/restore/SKILL.md +0 -55
  145. package/skills/security-review/SKILL.md +0 -87
  146. package/skills/session-end/SKILL.md +0 -100
  147. package/skills/session-init/SKILL.md +0 -45
  148. package/skills/slop-check/SKILL.md +0 -40
  149. package/skills/write-evals/SKILL.md +0 -34
  150. package/skills/write-tests/SKILL.md +0 -54
  151. /package/watchtower/{auto-checkpoint-prompt.md → legacy/auto-checkpoint-prompt.md} +0 -0
@@ -0,0 +1,41 @@
1
+ # Stack mode
2
+
3
+ Map an entire tech stack — multiple services, how they connect, and the system-level
4
+ architecture.
5
+
6
+ The orchestrator has already loaded for you: the target (`$ARGUMENTS`), the chosen
7
+ `MODE`, the session output directory, the target's CLAUDE.md / DANGER-ZONES.md, Graphify
8
+ availability, the **service discovery + Dockerfile/infra/contract listings**, and any
9
+ prior `map-stack.md` artifacts. Use them; don't re-fetch.
10
+
11
+ ## Process
12
+
13
+ If Graphify is available (see the orchestrator's availability check), run
14
+ `graphify run [target] --directed` for a unified cross-service graph and fold its
15
+ findings into the map below.
16
+
17
+ 1. **Inventory every service** — name, language/framework, what it does, how it deploys.
18
+ 2. **Map connections BETWEEN services** — REST/HTTP base URLs and clients, gRPC/proto
19
+ stubs, message queues (Kafka/RabbitMQ/SQS topics), shared databases, shared internal
20
+ libraries, service URLs in env vars.
21
+ 3. **Trace the data flow** — where data enters, how it moves, where it ends up.
22
+
23
+ ## Output
24
+
25
+ Write the full map to `map-stack.md` inside the session output directory printed by the
26
+ orchestrator (e.g. `uv-out/sessions/<sid>/map-stack.md`), stamped with provenance
27
+ frontmatter (`session`, `skill: understand`, `created`). Include:
28
+
29
+ - A **System Architecture Diagram** (Mermaid): every service as a node, connections
30
+ labeled (REST, gRPC, Kafka, shared DB), external deps, data stores.
31
+ - A **Stack Inventory Table**: Service | Language | Framework | Database | Deploys to | Depends on | Depended on by.
32
+ - A **Connection Matrix**: which services talk to which, and how.
33
+ - **Danger Zones**: single points of failure, high-inbound-dependency services, shared
34
+ databases, missing monitoring/health checks.
35
+
36
+ ## Report back
37
+
38
+ After writing the file, print only a one-line pointer to the terminal — do not repeat
39
+ the map. For example:
40
+
41
+ > Map written to `uv-out/sessions/<sid>/map-stack.md` — go check it.
@@ -28,8 +28,7 @@ Every skill accepts free-form arguments to direct the agent. Examples shown belo
28
28
 
29
29
  | Skill | What it does | Example |
30
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` |
31
+ | `/understand [target]` | Map a codebase or stack (auto-detects scope) | `/understand focus on the auth flow and session management` |
33
32
 
34
33
  ### Plan
35
34
 
@@ -42,8 +41,10 @@ Every skill accepts free-form arguments to direct the agent. Examples shown belo
42
41
 
43
42
  | Skill | What it does | Example |
44
43
  |-------|-------------|---------|
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` |
44
+ | `/test [target]` | Generate tests matching project conventions | `/test src/auth/login.ts focus on error paths` |
45
+ | `/test --unit [target]` | Write unit tests | `/test --unit src/auth/login.ts focus on error paths` |
46
+ | `/test --integration [target]` | Write integration tests | `/test --integration the checkout flow end to end` |
47
+ | `/test --eval [prompt]` | Write AI/LLM evaluation cases | `/test --eval test the search ranking prompt for adversarial inputs` |
47
48
  | `/prototype [concept]` | Build a static React prototype | `/prototype event booking app with calendar and payment flow` |
48
49
 
49
50
  ### Review
@@ -51,8 +52,8 @@ Every skill accepts free-form arguments to direct the agent. Examples shown belo
51
52
  | Skill | What it does | Example |
52
53
  |-------|-------------|---------|
53
54
  | `/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` |
55
+ | `/review --slop [target]` | Detect 6 categories of AI-generated slop | `/review --slop src/components/ check for over-engineering` |
56
+ | `/review --security [target]` | OWASP audit, dependency scan, secret detection | `/review --security src/payments/ focus on webhook signature validation` |
56
57
 
57
58
  ### Ship
58
59
 
@@ -65,31 +66,45 @@ Every skill accepts free-form arguments to direct the agent. Examples shown belo
65
66
 
66
67
  | Skill | What it does | Example |
67
68
  |-------|-------------|---------|
68
- | `/checkpoint [label]` | Save session state for next time | `/checkpoint auth-refactor` |
69
- | `/restore` | Load latest checkpoint at session start | `/restore` |
69
+ | `/session init` | Start a new session and set up artifacts | `/session init` |
70
+ | `/session checkpoint [label]` | Save session state for next time | `/session checkpoint auth-refactor` |
71
+ | `/session restore` | Load latest checkpoint at session start | `/session restore` |
72
+ | `/session end` | Wrap up and persist final session state | `/session end` |
73
+ | `/session auto` | Auto-checkpoint on a cadence during work | `/session auto` |
74
+
75
+ ### QA
76
+
77
+ | Skill | What it does | Example |
78
+ |-------|-------------|---------|
79
+ | `/qa [target]` | Manual/exploratory QA of a change or flow | `/qa walk through the new checkout flow` |
80
+
81
+ ### Util
82
+
83
+ | Skill | What it does | Example |
84
+ |-------|-------------|---------|
85
+ | `/confirm [question]` | Ask for explicit confirmation before acting | `/confirm before deleting the staging database` |
86
+ | `/uv-help [topic]` | Show all UV Suite skills, agents, and hooks | `/uv-help review` |
70
87
 
71
88
  ## Agents (spawned by skills)
72
89
 
73
90
  | Agent | Model | Used by |
74
91
  |-------|-------|---------|
75
- | Cartographer | Opus | /map-codebase, /map-stack |
92
+ | Cartographer | Opus | /understand |
76
93
  | Spec Writer | Opus | /spec |
77
94
  | Architect | Opus | /architect |
78
95
  | Reviewer | Opus | /review, /investigate |
79
- | Test Writer | Sonnet | /write-tests |
80
- | Eval Writer | Opus | /write-evals |
81
- | Anti-Slop Guard | Opus | /slop-check |
96
+ | Test Writer | Sonnet | /test |
97
+ | Eval Writer | Opus | /test --eval |
98
+ | Anti-Slop Guard | Opus | /review --slop |
82
99
  | Prototype Builder | Sonnet | /prototype |
83
- | DevOps | Opus | (direct invocation) |
84
- | Security | Opus | /security-review |
85
100
 
86
101
  ## Hooks (automatic, you don't invoke these)
87
102
 
88
- !`ls .claude/hooks/*.sh 2>/dev/null | while read f; do echo "- $(basename $f)"; done || echo "No hooks installed"`
103
+ !`ls .claude/hooks/ 2>/dev/null | grep '\.sh$' | sed 's/^/- /' || echo "No hooks installed"`
89
104
 
90
105
  ## Guardrails (anti-slop rules, active as context)
91
106
 
92
- !`ls .claude/rules/*.md 2>/dev/null | while read f; do echo "- $(basename $f .md)"; done || echo "No guardrails installed"`
107
+ !`ls .claude/rules/ 2>/dev/null | sed -n 's/\.md$//p' | sed 's/^/- /' || echo "No guardrails installed"`
93
108
 
94
109
  ## Personas
95
110
 
@@ -102,13 +117,21 @@ Every skill accepts free-form arguments to direct the agent. Examples shown belo
102
117
 
103
118
  ## Artifacts
104
119
 
105
- All agent output goes to `uv-out/`. Agents read each other's prior output automatically.
120
+ All agent output goes to `uv-out/sessions/<session-id>/`, so every artifact is attributable
121
+ to the session that produced it; `uv-out/current` points at the active session. Skills read
122
+ each other's prior output automatically (current session first, then prior sessions).
123
+
124
+ ### This session's artifacts
125
+
126
+ !`ls -R uv-out/current 2>/dev/null | head -20 || echo "No artifacts yet — run a skill to generate some"`
127
+
128
+ ### All sessions
106
129
 
107
- !`ls uv-out/*.md uv-out/**/*.md 2>/dev/null | head -15 || echo "No artifacts yet — run a skill to generate some"`
130
+ !`ls -1 uv-out/sessions 2>/dev/null | head -15 || echo "No sessions yet"`
108
131
 
109
132
  ## Tips
110
133
 
111
134
  - **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.
135
+ - **Run in parallel:** "Run /review, /review --slop, and /review --security in parallel" — Claude spawns all three simultaneously. `--slop` and `--security` are modes of `/review`, not separate skills. Ambient slop detection also runs automatically as a PostToolUse hook on every write.
136
+ - **Checkpoint before stopping:** "/session checkpoint" saves your session state. "/session restore" loads it next time.
114
137
  - **Use the right persona:** `uvs spike` for research, `uvs pro` for production code, `uvs auto` to let it run.
package/uv.sh CHANGED
@@ -44,7 +44,7 @@ case "$1" in
44
44
  echo " Session metadata:"
45
45
  echo " On launch you'll be prompted for name, kind, purpose, and"
46
46
  echo " priority. Press Enter to skip any field; you'll be reminded"
47
- echo " until the session is named. Use /session-init to relabel."
47
+ echo " until the session is named. Use /session init to relabel."
48
48
  echo " Set UVS_NO_PROMPT=1 to suppress prompts entirely."
49
49
  echo ""
50
50
  exit 0
@@ -151,6 +151,39 @@ echo "$UVS_SESSION_ID" > "$STATE_DIR/current-session.txt"
151
151
 
152
152
  SETTINGS=".claude/personas/$PERSONA.json"
153
153
 
154
+ # Launch the session, transparently wrapped in a tmux session so Watchtower
155
+ # can attach to / observe the live session. Invisible to the user (status bar
156
+ # off, lands them straight in the session). Falls back to a plain exec when
157
+ # tmux is unavailable, UVS_NO_TMUX is set, or we're already inside the wrapper.
158
+ launch_session() {
159
+ if command -v tmux >/dev/null 2>&1 && [ -z "$UVS_NO_TMUX" ] && [ -z "$UVS_IN_TMUX" ]; then
160
+ local socket="uvs"
161
+ local tname="uvs_$UVS_SESSION_ID"
162
+ local wt_url="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
163
+
164
+ # Build the launch command, exporting the session id + re-entry guard and
165
+ # safely quoting every argument for the shell tmux spawns.
166
+ local launch_cmd
167
+ printf -v launch_cmd 'UVS_SESSION_ID=%q UVS_IN_TMUX=1 exec' "$UVS_SESSION_ID"
168
+ local arg
169
+ for arg in "$@"; do
170
+ printf -v launch_cmd '%s %q' "$launch_cmd" "$arg"
171
+ done
172
+
173
+ tmux -L "$socket" new-session -d -s "$tname" -c "$PWD" "$launch_cmd"
174
+ tmux -L "$socket" set -t "$tname" status off 2>/dev/null
175
+
176
+ curl -s "$wt_url/sessions/register" \
177
+ -H 'Content-Type: application/json' \
178
+ -d "{\"id\":\"$UVS_SESSION_ID\",\"tmux\":\"$tname\",\"pid\":$$,\"cwd\":\"$PWD\"}" \
179
+ >/dev/null 2>&1 || true
180
+
181
+ exec tmux -L "$socket" attach -t "$tname"
182
+ fi
183
+
184
+ exec "$@"
185
+ }
186
+
154
187
  if [ "$TOOL" = "claude" ]; then
155
188
  # --- Claude Code ---
156
189
  if ! command -v claude &>/dev/null; then
@@ -167,7 +200,7 @@ if [ "$TOOL" = "claude" ]; then
167
200
  echo "UV Suite | Claude Code | $LABEL"
168
201
  echo "Session: ${UVS_SESSION_ID:0:8}${UVS_NAME:+ — $UVS_NAME}"
169
202
  echo ""
170
- exec claude --settings "$SETTINGS" "$@"
203
+ launch_session claude --settings "$SETTINGS" "$@"
171
204
 
172
205
  elif [ "$TOOL" = "codex" ]; then
173
206
  # --- OpenAI Codex ---
@@ -188,5 +221,5 @@ elif [ "$TOOL" = "codex" ]; then
188
221
  echo "UV Suite | Codex | $LABEL"
189
222
  echo "Session: ${UVS_SESSION_ID:0:8}${UVS_NAME:+ — $UVS_NAME}"
190
223
  echo ""
191
- exec codex $CODEX_ARGS "$@"
224
+ launch_session codex $CODEX_ARGS "$@"
192
225
  fi
@@ -0,0 +1,9 @@
1
+ FROM python:3.12-slim
2
+ WORKDIR /srv
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY schema.sql .
6
+ COPY app ./app
7
+ COPY static ./static
8
+ EXPOSE 4200
9
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]
@@ -0,0 +1,78 @@
1
+ # Watchtower (Python control plane)
2
+
3
+ Observability **and control** for UV Suite sessions. Hooks push events; the dashboard
4
+ observes *and* acts — checkpoint a session, close it, or approve one that's blocked waiting
5
+ for a human — from the browser.
6
+
7
+ FastAPI + Postgres. A hook insert fires `pg_notify`; a listener forwards it to connected
8
+ dashboards over WebSocket (no polling).
9
+
10
+ ## Run
11
+
12
+ ```bash
13
+ cd watchtower
14
+ docker compose up --build # Postgres + the service on :4200
15
+ # open http://localhost:4200
16
+ ```
17
+
18
+ Local (without Docker), against your own Postgres:
19
+ ```bash
20
+ export DATABASE_URL=postgresql://watchtower:watchtower@localhost:5432/watchtower
21
+ uv run --with-requirements requirements.txt uvicorn app.main:app --host 127.0.0.1 --port 4200
22
+ ```
23
+ > Bind control to **127.0.0.1** — the control API can checkpoint/kill/approve sessions.
24
+
25
+ ## How sessions become controllable
26
+
27
+ `uvs <tool> <persona>` launches the session inside a transparent tmux (`tmux -L uvs`,
28
+ status bar off) and registers it, so Watchtower can `send-keys`/`kill-session` against it.
29
+ No tmux (or `UVS_NO_TMUX=1`) → plain launch; checkpoint still works (out-of-band), close
30
+ works via PID, approve is unavailable from the UI.
31
+
32
+ ## API
33
+
34
+ | Group | Endpoints |
35
+ |---|---|
36
+ | Ingest | `POST /events`, `POST /sessions/register`, `POST /approvals`, `POST /sessions/{id}/state` |
37
+ | Query | `GET /sessions`, `/sessions/{id}`, `/sessions/{id}/events`, `/sessions/{id}/artifacts`, `/approvals?status=` |
38
+ | Stream | `WS /live` |
39
+ | Control | `POST /sessions/{id}/checkpoint \| close \| approve`, `POST /sessions/spawn` |
40
+
41
+ ## The three control actions
42
+ - **Checkpoint** — out-of-band: built from recent events + git state, written to
43
+ `uv-out/sessions/<id>/checkpoints/`. Instant, works for any session.
44
+ - **Close** — checkpoint, then `tmux kill-session` (owned) or `SIGTERM` (by PID).
45
+ - **Approve** — a `Notification` hook reports the pending permission request; the dashboard
46
+ shows it; clicking Approve/Deny `send-keys` the answer into the session's pane.
47
+
48
+ ## Layout
49
+ ```
50
+ app/
51
+ main.py app wiring + lifespan + dashboard serving
52
+ db.py asyncpg pool + NOTIFY → WebSocket broadcaster
53
+ models.py request models
54
+ routers/ ingest.py · query.py · stream.py · control.py
55
+ services/ checkpoint.py (out-of-band) · tmux.py (send-keys/kill/capture)
56
+ static/dashboard.html
57
+ schema.sql · Dockerfile · docker-compose.yml · requirements.txt
58
+ ```
59
+
60
+ ## Status / follow-ups
61
+ - The `Notification` hook (`hooks/watchtower-notify.sh`) still needs wiring into
62
+ `personas/*.json` (a `Notification` event entry) for approve to fire.
63
+ - The approve keystroke map (`y`/`n`) is a placeholder — Claude Code's permission UI may
64
+ use arrow-select + Enter. Validate against the installed version (`capture_pane` shows it).
65
+ - The semantic (haiku) checkpoint summary is a TODO; v1 checkpoints are mechanical.
66
+ - Supersedes the old Node `server.js`/`dashboard.html`/`*-runner.js` (kept for now).
67
+
68
+ ## Legacy fallback (Node)
69
+
70
+ The original Node Watchtower lives under `watchtower/legacy/` as a fallback that needs
71
+ **no Docker/Postgres**:
72
+
73
+ ```bash
74
+ uvs watch --legacy # runs legacy/server.js on :4200 (flat events.json, SSE)
75
+ ```
76
+
77
+ `uvs watch` (no flag) runs the Python service. The legacy one is observe-only (no control
78
+ plane) and is kept while the Python service is validated in real use.
File without changes
@@ -0,0 +1,85 @@
1
+ """Async Postgres layer + a NOTIFY → WebSocket broadcaster.
2
+
3
+ A hook insert calls `notify()` which `pg_notify`s on CHANNEL; a dedicated listener
4
+ connection forwards each payload to the Broadcaster, which fans it out to connected
5
+ WebSocket subscribers. No polling.
6
+ """
7
+ import asyncio
8
+ import json
9
+ import os
10
+
11
+ import asyncpg
12
+
13
+ DATABASE_URL = os.environ.get(
14
+ "DATABASE_URL", "postgresql://watchtower:watchtower@localhost:5432/watchtower"
15
+ )
16
+ CHANNEL = "watchtower_events"
17
+ SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "schema.sql")
18
+
19
+ pool: asyncpg.Pool | None = None
20
+
21
+
22
+ class Broadcaster:
23
+ """Fan out NOTIFY payloads (JSON strings) to subscribed WebSocket clients."""
24
+
25
+ def __init__(self) -> None:
26
+ self._subs: set[asyncio.Queue] = set()
27
+
28
+ def subscribe(self) -> asyncio.Queue:
29
+ q: asyncio.Queue = asyncio.Queue(maxsize=1000)
30
+ self._subs.add(q)
31
+ return q
32
+
33
+ def unsubscribe(self, q: asyncio.Queue) -> None:
34
+ self._subs.discard(q)
35
+
36
+ def publish(self, payload: str) -> None:
37
+ for q in list(self._subs):
38
+ try:
39
+ q.put_nowait(payload)
40
+ except asyncio.QueueFull:
41
+ pass # slow consumer — drop rather than block ingest
42
+
43
+
44
+ broadcaster = Broadcaster()
45
+
46
+
47
+ async def init_pool() -> None:
48
+ global pool
49
+ async def _init(con):
50
+ await con.set_type_codec(
51
+ "jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
52
+ )
53
+
54
+ pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=10, init=_init)
55
+ with open(SCHEMA_PATH) as f:
56
+ schema = f.read()
57
+ async with pool.acquire() as con:
58
+ await con.execute(schema)
59
+
60
+
61
+ async def listen_notify() -> None:
62
+ """Dedicated connection LISTENing on CHANNEL; forwards payloads to the broadcaster."""
63
+ con = await asyncpg.connect(DATABASE_URL)
64
+ await con.add_listener(CHANNEL, lambda *args: broadcaster.publish(args[-1]))
65
+ try:
66
+ while True:
67
+ await asyncio.sleep(3600)
68
+ finally:
69
+ await con.close()
70
+
71
+
72
+ async def close_pool() -> None:
73
+ if pool:
74
+ await pool.close()
75
+
76
+
77
+ async def db() -> asyncpg.Connection:
78
+ """FastAPI dependency: yields a pooled connection."""
79
+ async with pool.acquire() as con:
80
+ yield con
81
+
82
+
83
+ async def notify(con: asyncpg.Connection, payload: dict) -> None:
84
+ """Emit a NOTIFY so the stream router pushes this to dashboards."""
85
+ await con.execute("SELECT pg_notify($1, $2)", CHANNEL, json.dumps(payload, default=str))
@@ -0,0 +1,45 @@
1
+ """Watchtower service entrypoint. Wires the routers built independently in app/routers/."""
2
+ import asyncio
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from app import db
11
+ from app.routers import control, ingest, query, stream
12
+
13
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static")
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI):
18
+ await db.init_pool()
19
+ listener = asyncio.create_task(db.listen_notify())
20
+ try:
21
+ yield
22
+ finally:
23
+ listener.cancel()
24
+ await db.close_pool()
25
+
26
+
27
+ app = FastAPI(title="Watchtower", version="1.0.0", lifespan=lifespan)
28
+
29
+ app.include_router(ingest.router)
30
+ app.include_router(query.router)
31
+ app.include_router(stream.router)
32
+ app.include_router(control.router)
33
+
34
+
35
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
36
+
37
+
38
+ @app.get("/")
39
+ async def dashboard() -> FileResponse:
40
+ return FileResponse(os.path.join(STATIC_DIR, "dashboard.html"))
41
+
42
+
43
+ @app.get("/health")
44
+ async def health() -> dict:
45
+ return {"status": "ok"}
@@ -0,0 +1,49 @@
1
+ """Request/response models. Kept permissive — hook payloads carry arbitrary extra keys."""
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+
7
+ class EventIn(BaseModel):
8
+ """A hook event. Most fields arrive inside the raw hook JSON; we pull out the
9
+ common columns and keep the whole thing in `payload`."""
10
+ model_config = ConfigDict(extra="allow")
11
+
12
+ event_type: str = "Unknown"
13
+ uvs_session_id: str | None = None
14
+ tool_name: str | None = None
15
+ command: str | None = None
16
+
17
+
18
+ class SessionRegister(BaseModel):
19
+ id: str
20
+ name: str | None = None
21
+ persona: str | None = None
22
+ cwd: str | None = None
23
+ worktree: str | None = None
24
+ branch: str | None = None
25
+ pid: int | None = None
26
+ tmux_target: str | None = None
27
+
28
+
29
+ class StateUpdate(BaseModel):
30
+ state: Literal["active", "idle", "awaiting_human", "terminated"]
31
+
32
+
33
+ class ApprovalIn(BaseModel):
34
+ session_id: str
35
+ tool_name: str | None = None
36
+ command: str | None = None
37
+ request: dict[str, Any] | None = None
38
+
39
+
40
+ class ApprovalDecision(BaseModel):
41
+ decision: Literal["approve", "deny"]
42
+ decided_by: str | None = None
43
+
44
+
45
+ class SpawnRequest(BaseModel):
46
+ tool: Literal["claude", "codex"] = "claude"
47
+ persona: str = "professional"
48
+ cwd: str | None = None
49
+ task: str | None = None
File without changes
@@ -0,0 +1,144 @@
1
+ """Control router: drives sessions (spawn, close, approve, checkpoint).
2
+
3
+ # SECURITY: these routes act on the host — they spawn processes, send keystrokes,
4
+ # kill PIDs, and write files. They must NOT be exposed to the network. The service
5
+ # is started with `--host 127.0.0.1`; keep it bound to localhost.
6
+ """
7
+ import asyncio
8
+ import os
9
+ import shutil
10
+ import signal
11
+ import uuid
12
+
13
+ import asyncpg
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+
16
+ from app import db
17
+ from app.models import ApprovalDecision, SpawnRequest
18
+ from app.services import checkpoint, tmux
19
+
20
+ router = APIRouter()
21
+
22
+ # Claude Code's permission gate is a numbered arrow-select menu confirmed with Enter and
23
+ # cancelled with Esc (verified live: the trust-folder and tool-permission prompts use the
24
+ # same widget — it is NOT a y/n prompt). Approve = select option 1 ("Yes") + Enter;
25
+ # deny = Esc. This is the one part coupled to the tool's TUI; revisit if the widget changes.
26
+
27
+
28
+ async def _load_session(id: str, con: asyncpg.Connection) -> dict:
29
+ row = await con.fetchrow("SELECT * FROM sessions WHERE id = $1", id)
30
+ if row is None:
31
+ raise HTTPException(status_code=404, detail=f"session {id} not found")
32
+ return dict(row)
33
+
34
+
35
+ @router.post("/sessions/{id}/checkpoint")
36
+ async def checkpoint_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> dict:
37
+ session = await _load_session(id, con)
38
+ path = await checkpoint.write_checkpoint(session)
39
+ await db.notify(con, {"type": "checkpoint", "session_id": id, "path": path})
40
+ return {"path": path}
41
+
42
+
43
+ @router.post("/sessions/{id}/close")
44
+ async def close_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> dict:
45
+ session = await _load_session(id, con)
46
+
47
+ path = await checkpoint.write_checkpoint(session)
48
+
49
+ terminated_via = None
50
+ if session.get("tmux_target"):
51
+ await asyncio.to_thread(tmux.kill_session, session["tmux_target"])
52
+ terminated_via = "tmux"
53
+ elif session.get("pid"):
54
+ try:
55
+ os.kill(session["pid"], signal.SIGTERM)
56
+ terminated_via = "pid"
57
+ except ProcessLookupError:
58
+ terminated_via = "pid_gone"
59
+
60
+ await con.execute(
61
+ "UPDATE sessions SET state = 'terminated', ended_at = now() WHERE id = $1", id
62
+ )
63
+ await db.notify(con, {"type": "session", "session_id": id, "state": "terminated"})
64
+ return {"ok": True, "checkpoint": path, "terminated_via": terminated_via}
65
+
66
+
67
+ @router.post("/sessions/{id}/approve")
68
+ async def approve_session(
69
+ id: str, decision: ApprovalDecision, con: asyncpg.Connection = Depends(db.db)
70
+ ) -> dict:
71
+ session = await _load_session(id, con)
72
+
73
+ if not session.get("tmux_target"):
74
+ raise HTTPException(
75
+ status_code=400,
76
+ detail="session not owned by Watchtower; approve in the terminal",
77
+ )
78
+
79
+ approval = await con.fetchrow(
80
+ """SELECT id FROM approvals
81
+ WHERE session_id = $1 AND status = 'pending'
82
+ ORDER BY created_at DESC
83
+ LIMIT 1""",
84
+ id,
85
+ )
86
+ if approval is None:
87
+ raise HTTPException(status_code=404, detail="no pending approval for session")
88
+
89
+ target = session["tmux_target"]
90
+ # Read the current prompt before answering (surfaced for debugging / UI).
91
+ prompt = await asyncio.to_thread(tmux.capture_pane, target)
92
+ if decision.decision == "approve":
93
+ await asyncio.to_thread(tmux.send_keys, target, "1", True) # select "Yes" + Enter
94
+ else:
95
+ await asyncio.to_thread(tmux.send_keys, target, "Escape", False) # Esc cancels → reject
96
+
97
+ new_status = "approved" if decision.decision == "approve" else "denied"
98
+ await con.execute(
99
+ """UPDATE approvals
100
+ SET status = $2, decided_by = $3, decided_at = now()
101
+ WHERE id = $1""",
102
+ approval["id"], new_status, decision.decided_by,
103
+ )
104
+ await con.execute("UPDATE sessions SET state = 'active' WHERE id = $1", id)
105
+ await db.notify(con, {
106
+ "type": "approval",
107
+ "session_id": id,
108
+ "status": new_status,
109
+ })
110
+ return {"ok": True, "status": new_status, "prompt": prompt[-500:]}
111
+
112
+
113
+ @router.post("/sessions/spawn")
114
+ async def spawn_session(req: SpawnRequest, con: asyncpg.Connection = Depends(db.db)) -> dict:
115
+ if not tmux.has_tmux():
116
+ raise HTTPException(status_code=400, detail="tmux not available; cannot spawn")
117
+
118
+ id = uuid.uuid4().hex
119
+ cwd = req.cwd or os.getcwd()
120
+
121
+ # Prefer the real `uvs <tool> <persona>` launcher; fall back to the bare tool.
122
+ if shutil.which("uvs"):
123
+ launch = f"uvs {req.tool} {req.persona}"
124
+ else:
125
+ launch = req.tool
126
+ cmd = f"UVS_SESSION_ID={id} {launch}"
127
+
128
+ try:
129
+ target = await asyncio.to_thread(tmux.spawn, id, cmd, cwd)
130
+ except RuntimeError as e:
131
+ raise HTTPException(status_code=400, detail=str(e))
132
+
133
+ await con.execute(
134
+ """INSERT INTO sessions (id, persona, cwd, tmux_target, state)
135
+ VALUES ($1, $2, $3, $4, 'active')
136
+ ON CONFLICT (id) DO UPDATE SET
137
+ persona = COALESCE($2, sessions.persona),
138
+ cwd = COALESCE($3, sessions.cwd),
139
+ tmux_target = $4,
140
+ state = 'active'""",
141
+ id, req.persona, cwd, target,
142
+ )
143
+ await db.notify(con, {"type": "session", "session_id": id, "state": "active"})
144
+ return {"id": id, "tmux_target": target}