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.
- package/LICENSE +21 -0
- package/README.md +58 -35
- package/agents/claude-code/anti-slop-guard.md +14 -1
- package/agents/claude-code/architect.md +30 -4
- package/agents/claude-code/cartographer.md +18 -6
- package/agents/claude-code/eval-writer.md +7 -2
- package/agents/claude-code/reviewer.md +5 -1
- package/agents/claude-code/spec-writer.md +30 -7
- package/agents/generate.py +88 -0
- package/bin/cli.js +51 -48
- package/hooks/auto-checkpoint-helper.sh +2 -2
- package/hooks/auto-checkpoint.sh +3 -3
- package/hooks/auto-restore-on-start.sh +30 -0
- package/hooks/checkpoint-helper.sh +40 -35
- package/hooks/git-context.sh +41 -0
- package/hooks/lite-mode-inject.sh +26 -0
- package/hooks/session-end-helper.sh +2 -2
- package/hooks/session-end.sh +2 -2
- package/hooks/session-label-nag.sh +2 -2
- package/hooks/session-meta.sh +18 -1
- package/hooks/session-review-reminder.sh +2 -2
- package/hooks/session-start.sh +16 -0
- package/hooks/slop-grep.sh +12 -31
- package/hooks/uv-out-best.sh +20 -0
- package/hooks/uv-out-collect.sh +52 -0
- package/hooks/uv-out-notify.sh +28 -0
- package/hooks/uv-out-pointer.sh +16 -0
- package/hooks/uv-out-session.sh +24 -0
- package/hooks/watchtower-notify.sh +45 -0
- package/hooks/watchtower-send.sh +4 -0
- package/install.sh +93 -42
- package/package.json +2 -2
- package/personas/auto.json +40 -1
- package/personas/professional.json +46 -1
- package/personas/spike.json +32 -2
- package/personas/sport.json +44 -1
- package/settings.json +6 -2
- package/skills/architect/SKILL.md +109 -8
- package/skills/architect/specialists/distributed-systems.md +84 -0
- package/skills/architect/specialists/full-stack.md +92 -0
- package/skills/architect/specialists/llm-ai-engineering.md +86 -0
- package/skills/architect/specialists/ml-systems.md +81 -0
- package/skills/commit/SKILL.md +5 -2
- package/skills/confirm/SKILL.md +3 -3
- package/skills/investigate/SKILL.md +14 -4
- package/skills/lite/SKILL.md +45 -0
- package/skills/qa/SKILL.md +274 -0
- package/skills/review/SKILL.md +187 -8
- package/skills/review/specialists/api-contract.md +122 -0
- package/skills/review/specialists/architecture-trace.md +64 -0
- package/skills/review/specialists/data-migration.md +113 -0
- package/skills/review/specialists/maintainability.md +138 -0
- package/skills/review/specialists/performance.md +115 -0
- package/skills/review/specialists/security.md +132 -0
- package/skills/review/specialists/testing.md +109 -0
- package/skills/session/SKILL.md +87 -0
- package/skills/session/operations/auto.md +22 -0
- package/skills/session/operations/checkpoint.md +43 -0
- package/skills/session/operations/end.md +35 -0
- package/skills/session/operations/init.md +16 -0
- package/skills/session/operations/restore.md +16 -0
- package/skills/spec/SKILL.md +40 -1
- package/skills/test/SKILL.md +89 -0
- package/skills/test/specialists/eval.md +46 -0
- package/skills/test/specialists/integration.md +42 -0
- package/skills/test/specialists/unit.md +39 -0
- package/skills/understand/SKILL.md +118 -0
- package/skills/understand/modes/repo.md +38 -0
- package/skills/understand/modes/stack.md +41 -0
- package/skills/uv-help/SKILL.md +43 -20
- package/uv.sh +36 -3
- package/watchtower/Dockerfile +9 -0
- package/watchtower/README.md +78 -0
- package/watchtower/app/__init__.py +0 -0
- package/watchtower/app/__pycache__/__init__.cpython-314.pyc +0 -0
- package/watchtower/app/__pycache__/db.cpython-314.pyc +0 -0
- package/watchtower/app/__pycache__/main.cpython-314.pyc +0 -0
- package/watchtower/app/__pycache__/models.cpython-314.pyc +0 -0
- package/watchtower/app/db.py +85 -0
- package/watchtower/app/main.py +45 -0
- package/watchtower/app/models.py +49 -0
- package/watchtower/app/routers/__init__.py +0 -0
- package/watchtower/app/routers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/watchtower/app/routers/__pycache__/control.cpython-314.pyc +0 -0
- package/watchtower/app/routers/__pycache__/ingest.cpython-314.pyc +0 -0
- package/watchtower/app/routers/__pycache__/query.cpython-314.pyc +0 -0
- package/watchtower/app/routers/__pycache__/stream.cpython-314.pyc +0 -0
- package/watchtower/app/routers/control.py +144 -0
- package/watchtower/app/routers/ingest.py +102 -0
- package/watchtower/app/routers/query.py +84 -0
- package/watchtower/app/routers/stream.py +30 -0
- package/watchtower/app/services/__init__.py +0 -0
- package/watchtower/app/services/__pycache__/__init__.cpython-314.pyc +0 -0
- package/watchtower/app/services/__pycache__/checkpoint.cpython-314.pyc +0 -0
- package/watchtower/app/services/__pycache__/tmux.cpython-314.pyc +0 -0
- package/watchtower/app/services/checkpoint.py +107 -0
- package/watchtower/app/services/tmux.py +54 -0
- package/watchtower/docker-compose.yml +22 -0
- package/watchtower/events.json +10373 -1
- package/watchtower/{auto-checkpoint-runner.js → legacy/auto-checkpoint-runner.js} +29 -2
- package/watchtower/{dashboard.html → legacy/dashboard.html} +261 -0
- package/watchtower/{server.js → legacy/server.js} +63 -0
- package/watchtower/legacy/snapshot-manager.js +305 -0
- package/watchtower/requirements.txt +3 -0
- package/watchtower/schema.sql +43 -0
- package/watchtower/static/dashboard.html +449 -0
- package/agents/claude-code/devops.md +0 -50
- package/agents/claude-code/security.md +0 -75
- package/agents/codex/anti-slop-guard.toml +0 -12
- package/agents/codex/architect.toml +0 -11
- package/agents/codex/cartographer.toml +0 -16
- package/agents/codex/devops.toml +0 -8
- package/agents/codex/eval-writer.toml +0 -11
- package/agents/codex/prototype-builder.toml +0 -10
- package/agents/codex/reviewer.toml +0 -16
- package/agents/codex/security.toml +0 -14
- package/agents/codex/spec-writer.toml +0 -11
- package/agents/codex/test-writer.toml +0 -13
- package/agents/cursor/anti-slop-guard.mdc +0 -22
- package/agents/cursor/architect.mdc +0 -24
- package/agents/cursor/cartographer.mdc +0 -28
- package/agents/cursor/devops.mdc +0 -16
- package/agents/cursor/eval-writer.mdc +0 -21
- package/agents/cursor/prototype-builder.mdc +0 -25
- package/agents/cursor/reviewer.mdc +0 -26
- package/agents/cursor/security.mdc +0 -20
- package/agents/cursor/spec-writer.mdc +0 -27
- package/agents/cursor/test-writer.mdc +0 -28
- package/agents/portable/anti-slop-guard.md +0 -71
- package/agents/portable/architect.md +0 -83
- package/agents/portable/cartographer.md +0 -64
- package/agents/portable/devops.md +0 -56
- package/agents/portable/eval-writer.md +0 -70
- package/agents/portable/prototype-builder.md +0 -70
- package/agents/portable/reviewer.md +0 -79
- package/agents/portable/security.md +0 -63
- package/agents/portable/spec-writer.md +0 -89
- package/agents/portable/test-writer.md +0 -56
- package/hooks/context-warning.sh +0 -4
- package/skills/auto-checkpoint/SKILL.md +0 -47
- package/skills/checkpoint/SKILL.md +0 -105
- package/skills/map-codebase/SKILL.md +0 -54
- package/skills/map-stack/SKILL.md +0 -121
- package/skills/restore/SKILL.md +0 -55
- package/skills/security-review/SKILL.md +0 -87
- package/skills/session-end/SKILL.md +0 -100
- package/skills/session-init/SKILL.md +0 -45
- package/skills/slop-check/SKILL.md +0 -40
- package/skills/write-evals/SKILL.md +0 -34
- package/skills/write-tests/SKILL.md +0 -54
- /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.
|
package/skills/uv-help/SKILL.md
CHANGED
|
@@ -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
|
-
| `/
|
|
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
|
-
| `/
|
|
46
|
-
| `/
|
|
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
|
|
55
|
-
| `/
|
|
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
|
-
| `/
|
|
69
|
-
| `/
|
|
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 | /
|
|
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 | /
|
|
80
|
-
| Eval Writer | Opus | /
|
|
81
|
-
| Anti-Slop Guard | Opus | /slop
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
launch_session codex $CODEX_ARGS "$@"
|
|
192
225
|
fi
|
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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}
|