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,102 @@
|
|
|
1
|
+
"""Ingest router: hooks and `uvs` POST here. Writes events/sessions/approvals
|
|
2
|
+
and emits a NOTIFY so connected dashboards update without polling."""
|
|
3
|
+
import asyncpg
|
|
4
|
+
from fastapi import APIRouter, Depends, Request
|
|
5
|
+
|
|
6
|
+
from app import db
|
|
7
|
+
from app.models import ApprovalIn, SessionRegister, StateUpdate
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/events")
|
|
13
|
+
async def ingest_event(req: Request, con: asyncpg.Connection = Depends(db.db)) -> dict:
|
|
14
|
+
body = await req.json()
|
|
15
|
+
|
|
16
|
+
sid = body.get("uvs_session_id") or body.get("session_id")
|
|
17
|
+
event_type = body.get("event_type", "Unknown")
|
|
18
|
+
tool_name = body.get("tool_name")
|
|
19
|
+
command = body.get("command")
|
|
20
|
+
cwd = body.get("cwd")
|
|
21
|
+
|
|
22
|
+
name = body.get("session_name") or body.get("name")
|
|
23
|
+
kind = body.get("session_kind") or body.get("kind")
|
|
24
|
+
purpose = body.get("session_purpose") or body.get("purpose")
|
|
25
|
+
priority = body.get("session_priority") or body.get("priority")
|
|
26
|
+
persona = body.get("persona")
|
|
27
|
+
|
|
28
|
+
await con.execute(
|
|
29
|
+
"""INSERT INTO events (session_id, event_type, tool_name, command, payload)
|
|
30
|
+
VALUES ($1, $2, $3, $4, $5)""",
|
|
31
|
+
sid, event_type, tool_name, command, body,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if sid:
|
|
35
|
+
await con.execute(
|
|
36
|
+
"""INSERT INTO sessions (id, name, kind, purpose, priority, persona, cwd)
|
|
37
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
38
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
39
|
+
name = COALESCE($2, sessions.name),
|
|
40
|
+
kind = COALESCE($3, sessions.kind),
|
|
41
|
+
purpose = COALESCE($4, sessions.purpose),
|
|
42
|
+
priority = COALESCE($5, sessions.priority),
|
|
43
|
+
persona = COALESCE($6, sessions.persona),
|
|
44
|
+
cwd = COALESCE($7, sessions.cwd)""",
|
|
45
|
+
sid, name, kind, purpose, priority, persona, cwd,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
await db.notify(con, {"type": "event", "session_id": sid, "event_type": event_type})
|
|
49
|
+
return {"ok": True}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/sessions/register")
|
|
53
|
+
async def register_session(s: SessionRegister, con: asyncpg.Connection = Depends(db.db)) -> dict:
|
|
54
|
+
await con.execute(
|
|
55
|
+
"""INSERT INTO sessions (id, name, persona, cwd, worktree, branch, pid, tmux_target, state)
|
|
56
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')
|
|
57
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
58
|
+
name = COALESCE($2, sessions.name),
|
|
59
|
+
persona = COALESCE($3, sessions.persona),
|
|
60
|
+
cwd = COALESCE($4, sessions.cwd),
|
|
61
|
+
worktree = COALESCE($5, sessions.worktree),
|
|
62
|
+
branch = COALESCE($6, sessions.branch),
|
|
63
|
+
pid = COALESCE($7, sessions.pid),
|
|
64
|
+
tmux_target = COALESCE($8, sessions.tmux_target),
|
|
65
|
+
state = 'active'""",
|
|
66
|
+
s.id, s.name, s.persona, s.cwd, s.worktree, s.branch, s.pid, s.tmux_target,
|
|
67
|
+
)
|
|
68
|
+
await db.notify(con, {"type": "session", "session_id": s.id})
|
|
69
|
+
return {"ok": True}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/approvals")
|
|
73
|
+
async def create_approval(a: ApprovalIn, con: asyncpg.Connection = Depends(db.db)) -> dict:
|
|
74
|
+
row = await con.fetchrow(
|
|
75
|
+
"""INSERT INTO approvals (session_id, tool_name, command, request, status)
|
|
76
|
+
VALUES ($1, $2, $3, $4, 'pending')
|
|
77
|
+
RETURNING id""",
|
|
78
|
+
a.session_id, a.tool_name, a.command, a.request,
|
|
79
|
+
)
|
|
80
|
+
await con.execute(
|
|
81
|
+
"UPDATE sessions SET state = 'awaiting_human' WHERE id = $1", a.session_id
|
|
82
|
+
)
|
|
83
|
+
await db.notify(con, {
|
|
84
|
+
"type": "approval",
|
|
85
|
+
"id": row["id"],
|
|
86
|
+
"session_id": a.session_id,
|
|
87
|
+
"tool_name": a.tool_name,
|
|
88
|
+
"command": a.command,
|
|
89
|
+
})
|
|
90
|
+
return {"ok": True, "id": row["id"]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.post("/sessions/{id}/state")
|
|
94
|
+
async def update_state(id: str, s: StateUpdate, con: asyncpg.Connection = Depends(db.db)) -> dict:
|
|
95
|
+
if s.state == "terminated":
|
|
96
|
+
await con.execute(
|
|
97
|
+
"UPDATE sessions SET state = $2, ended_at = now() WHERE id = $1", id, s.state
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
await con.execute("UPDATE sessions SET state = $2 WHERE id = $1", id, s.state)
|
|
101
|
+
await db.notify(con, {"type": "session", "session_id": id, "state": s.state})
|
|
102
|
+
return {"ok": True}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Query router: read-only endpoints the dashboard polls/loads."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import asyncpg
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from app import db
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/sessions")
|
|
13
|
+
async def list_sessions(con: asyncpg.Connection = Depends(db.db)) -> list[dict]:
|
|
14
|
+
rows = await con.fetch(
|
|
15
|
+
"""SELECT s.*,
|
|
16
|
+
(SELECT count(*) FROM events e
|
|
17
|
+
WHERE e.session_id = s.id AND e.tool_name IS NOT NULL) AS tool_calls,
|
|
18
|
+
(SELECT max(e.created_at) FROM events e
|
|
19
|
+
WHERE e.session_id = s.id) AS last_event_at
|
|
20
|
+
FROM sessions s
|
|
21
|
+
ORDER BY COALESCE(
|
|
22
|
+
(SELECT max(e.created_at) FROM events e WHERE e.session_id = s.id),
|
|
23
|
+
s.started_at) DESC"""
|
|
24
|
+
)
|
|
25
|
+
return [dict(r) for r in rows]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/sessions/{id}")
|
|
29
|
+
async def get_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> dict:
|
|
30
|
+
row = await con.fetchrow("SELECT * FROM sessions WHERE id = $1", id)
|
|
31
|
+
if row is None:
|
|
32
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
33
|
+
return dict(row)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/sessions/{id}/events")
|
|
37
|
+
async def get_session_events(
|
|
38
|
+
id: str, limit: int = 100, con: asyncpg.Connection = Depends(db.db)
|
|
39
|
+
) -> list[dict]:
|
|
40
|
+
rows = await con.fetch(
|
|
41
|
+
"""SELECT * FROM events
|
|
42
|
+
WHERE session_id = $1
|
|
43
|
+
ORDER BY created_at DESC
|
|
44
|
+
LIMIT $2""",
|
|
45
|
+
id, limit,
|
|
46
|
+
)
|
|
47
|
+
return [dict(r) for r in rows]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/sessions/{id}/artifacts")
|
|
51
|
+
async def get_session_artifacts(
|
|
52
|
+
id: str, con: asyncpg.Connection = Depends(db.db)
|
|
53
|
+
) -> list[dict]:
|
|
54
|
+
row = await con.fetchrow("SELECT cwd FROM sessions WHERE id = $1", id)
|
|
55
|
+
if row is None:
|
|
56
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
57
|
+
|
|
58
|
+
cwd = row["cwd"]
|
|
59
|
+
if not cwd:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
base = os.path.join(cwd, "uv-out", "sessions", id)
|
|
63
|
+
if not os.path.isdir(base):
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
artifacts = []
|
|
67
|
+
for dirpath, _dirnames, filenames in os.walk(base):
|
|
68
|
+
for fn in filenames:
|
|
69
|
+
full = os.path.join(dirpath, fn)
|
|
70
|
+
artifacts.append({
|
|
71
|
+
"path": os.path.relpath(full, base),
|
|
72
|
+
"size": os.path.getsize(full),
|
|
73
|
+
})
|
|
74
|
+
return artifacts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/approvals")
|
|
78
|
+
async def list_approvals(
|
|
79
|
+
status: str = "pending", con: asyncpg.Connection = Depends(db.db)
|
|
80
|
+
) -> list[dict]:
|
|
81
|
+
rows = await con.fetch(
|
|
82
|
+
"SELECT * FROM approvals WHERE status = $1 ORDER BY created_at DESC", status
|
|
83
|
+
)
|
|
84
|
+
return [dict(r) for r in rows]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Stream router: a single WebSocket the dashboard connects to for live updates.
|
|
2
|
+
NOTIFY payloads land on the broadcaster (see app/db.py) and fan out here."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
7
|
+
|
|
8
|
+
from app import db
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.websocket("/live")
|
|
14
|
+
async def live(ws: WebSocket) -> None:
|
|
15
|
+
await ws.accept()
|
|
16
|
+
|
|
17
|
+
async with db.pool.acquire() as con:
|
|
18
|
+
rows = await con.fetch("SELECT * FROM sessions ORDER BY started_at DESC")
|
|
19
|
+
snapshot = {"type": "snapshot", "sessions": [dict(r) for r in rows]}
|
|
20
|
+
await ws.send_text(json.dumps(snapshot, default=str))
|
|
21
|
+
|
|
22
|
+
q = db.broadcaster.subscribe()
|
|
23
|
+
try:
|
|
24
|
+
while True:
|
|
25
|
+
payload = await q.get()
|
|
26
|
+
await ws.send_text(payload)
|
|
27
|
+
except (WebSocketDisconnect, asyncio.CancelledError):
|
|
28
|
+
pass
|
|
29
|
+
finally:
|
|
30
|
+
db.broadcaster.unsubscribe(q)
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Out-of-band session checkpoints.
|
|
2
|
+
|
|
3
|
+
Writes a markdown checkpoint from the session's recent events + git state.
|
|
4
|
+
No live session is required — everything is read from Postgres and the cwd's
|
|
5
|
+
git repo, so we can checkpoint a session we don't own (e.g. before closing it).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from app import db
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _git(cwd: str, *args: str) -> str:
|
|
15
|
+
"""Best-effort `git -C <cwd> <args>`; empty string on any failure."""
|
|
16
|
+
try:
|
|
17
|
+
proc = subprocess.run(
|
|
18
|
+
["git", "-C", cwd, *args],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=10,
|
|
22
|
+
)
|
|
23
|
+
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
24
|
+
except (OSError, subprocess.SubprocessError):
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git_state(cwd: str) -> str:
|
|
29
|
+
branch = _git(cwd, "branch", "--show-current") or "(unknown)"
|
|
30
|
+
status = _git(cwd, "status", "--short") or "(clean)"
|
|
31
|
+
log = _git(cwd, "log", "--oneline", "-5") or "(no commits)"
|
|
32
|
+
return (
|
|
33
|
+
f"- Branch: `{branch}`\n\n"
|
|
34
|
+
f"Working tree:\n```\n{status}\n```\n\n"
|
|
35
|
+
f"Recent commits:\n```\n{log}\n```\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render(session: dict, events: list, git_state: str, created: str) -> str:
|
|
40
|
+
sid = session["id"]
|
|
41
|
+
name = session.get("name") or sid
|
|
42
|
+
|
|
43
|
+
lines = []
|
|
44
|
+
for e in events:
|
|
45
|
+
ts = e["created_at"].isoformat() if e["created_at"] else ""
|
|
46
|
+
bits = [e["event_type"] or "Event"]
|
|
47
|
+
if e["tool_name"]:
|
|
48
|
+
bits.append(e["tool_name"])
|
|
49
|
+
if e["command"]:
|
|
50
|
+
bits.append(f"`{e['command']}`")
|
|
51
|
+
lines.append(f"- {ts} — {' '.join(bits)}")
|
|
52
|
+
activity = "\n".join(lines) if lines else "_No recorded events._"
|
|
53
|
+
|
|
54
|
+
return f"""---
|
|
55
|
+
session: {sid}
|
|
56
|
+
created: {created}
|
|
57
|
+
source: watchtower
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
# Checkpoint: {name}
|
|
61
|
+
|
|
62
|
+
## What's in progress
|
|
63
|
+
|
|
64
|
+
<!-- TODO: optional semantic summary via `claude -p --model haiku`.
|
|
65
|
+
v1 is mechanical (events + git). Do not depend on `claude` being present. -->
|
|
66
|
+
_Mechanical checkpoint — see recent activity and git state below._
|
|
67
|
+
|
|
68
|
+
## Recent activity
|
|
69
|
+
|
|
70
|
+
{activity}
|
|
71
|
+
|
|
72
|
+
## Git state
|
|
73
|
+
|
|
74
|
+
{git_state}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def write_checkpoint(session: dict) -> str:
|
|
79
|
+
sid = session["id"]
|
|
80
|
+
cwd = session.get("cwd") or os.getcwd()
|
|
81
|
+
|
|
82
|
+
rows = await db.pool.fetch(
|
|
83
|
+
"""SELECT event_type, tool_name, command, created_at
|
|
84
|
+
FROM events
|
|
85
|
+
WHERE session_id = $1
|
|
86
|
+
ORDER BY created_at DESC
|
|
87
|
+
LIMIT 50""",
|
|
88
|
+
sid,
|
|
89
|
+
)
|
|
90
|
+
events = [dict(r) for r in rows]
|
|
91
|
+
|
|
92
|
+
now = datetime.now(timezone.utc)
|
|
93
|
+
created = now.isoformat()
|
|
94
|
+
git_state = _git_state(cwd)
|
|
95
|
+
content = _render(session, events, git_state, created)
|
|
96
|
+
|
|
97
|
+
out_dir = os.path.join(cwd, "uv-out", "sessions", sid, "checkpoints")
|
|
98
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
99
|
+
fname = f"manual-{now.strftime('%Y-%m-%d-%H%M')}.md"
|
|
100
|
+
path = os.path.join(out_dir, fname)
|
|
101
|
+
|
|
102
|
+
with open(path, "w") as f:
|
|
103
|
+
f.write(content)
|
|
104
|
+
with open(os.path.join(out_dir, "latest.md"), "w") as f:
|
|
105
|
+
f.write(content)
|
|
106
|
+
|
|
107
|
+
return os.path.abspath(path)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Thin wrappers over `tmux -L uvs`.
|
|
2
|
+
|
|
3
|
+
We use a dedicated tmux socket (`-L uvs`) so Watchtower-spawned sessions never
|
|
4
|
+
clash with the user's own tmux server. Functions are synchronous subprocess
|
|
5
|
+
calls — invoke them from async code via `asyncio.to_thread`.
|
|
6
|
+
"""
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
SOCKET = "uvs"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _tmux(*args: str) -> tuple[bool, str]:
|
|
14
|
+
"""Run `tmux -L uvs <args>`; return (ok, combined output)."""
|
|
15
|
+
if not has_tmux():
|
|
16
|
+
return False, "tmux not found on PATH"
|
|
17
|
+
proc = subprocess.run(
|
|
18
|
+
["tmux", "-L", SOCKET, *args],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
)
|
|
22
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
23
|
+
return proc.returncode == 0, out.strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def has_tmux() -> bool:
|
|
27
|
+
return shutil.which("tmux") is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def spawn(session_id: str, command: str, cwd: str) -> str:
|
|
31
|
+
"""Start a detached session named `uvs_<id>`; return its target."""
|
|
32
|
+
target = f"uvs_{session_id}"
|
|
33
|
+
ok, out = _tmux("new-session", "-d", "-s", target, "-c", cwd, command)
|
|
34
|
+
if not ok:
|
|
35
|
+
raise RuntimeError(f"tmux new-session failed: {out}")
|
|
36
|
+
return target
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def send_keys(target: str, keys: str, enter: bool = True) -> tuple[bool, str]:
|
|
40
|
+
args = ["send-keys", "-t", target, keys]
|
|
41
|
+
if enter:
|
|
42
|
+
args.append("Enter")
|
|
43
|
+
return _tmux(*args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def capture_pane(target: str) -> str:
|
|
47
|
+
ok, out = _tmux("capture-pane", "-t", target, "-p")
|
|
48
|
+
if not ok:
|
|
49
|
+
raise RuntimeError(f"tmux capture-pane failed: {out}")
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def kill_session(target: str) -> tuple[bool, str]:
|
|
54
|
+
return _tmux("kill-session", "-t", target)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
services:
|
|
2
|
+
db:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: watchtower
|
|
6
|
+
POSTGRES_PASSWORD: watchtower
|
|
7
|
+
POSTGRES_DB: watchtower
|
|
8
|
+
healthcheck:
|
|
9
|
+
test: ["CMD-SHELL", "pg_isready -U watchtower"]
|
|
10
|
+
interval: 3s
|
|
11
|
+
timeout: 3s
|
|
12
|
+
retries: 10
|
|
13
|
+
|
|
14
|
+
app:
|
|
15
|
+
build: .
|
|
16
|
+
environment:
|
|
17
|
+
DATABASE_URL: postgresql://watchtower:watchtower@db:5432/watchtower
|
|
18
|
+
ports:
|
|
19
|
+
- "4200:4200" # control API binds localhost only — see app routers
|
|
20
|
+
depends_on:
|
|
21
|
+
db:
|
|
22
|
+
condition: service_healthy
|