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,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
@@ -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