uv-suite 0.26.5 → 0.27.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/hooks/auto-checkpoint-helper.sh +73 -0
- package/hooks/auto-checkpoint.sh +184 -0
- package/hooks/confirm-helper.sh +45 -0
- package/package.json +1 -1
- package/personas/auto.json +16 -2
- package/personas/professional.json +18 -2
- package/personas/spike.json +28 -7
- package/personas/sport.json +25 -1
- package/skills/auto-checkpoint/SKILL.md +47 -0
- package/skills/confirm/SKILL.md +4 -7
- package/watchtower/auto-checkpoint-prompt.md +51 -0
- package/watchtower/auto-checkpoint-runner.js +285 -0
- package/watchtower/dashboard.html +76 -8
- package/watchtower/server.js +21 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UV Suite helper: read or change auto-checkpoint settings.
|
|
3
|
+
# Used by the /auto-checkpoint slash command.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# auto-checkpoint-helper.sh status
|
|
7
|
+
# auto-checkpoint-helper.sh on
|
|
8
|
+
# auto-checkpoint-helper.sh off
|
|
9
|
+
# auto-checkpoint-helper.sh <minutes> # set interval
|
|
10
|
+
|
|
11
|
+
STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
|
|
12
|
+
mkdir -p "$STATE_DIR"
|
|
13
|
+
STATE_FILE="$STATE_DIR/auto-checkpoint.json"
|
|
14
|
+
|
|
15
|
+
ensure_state() {
|
|
16
|
+
[ -f "$STATE_FILE" ] && return
|
|
17
|
+
cat > "$STATE_FILE" <<'EOF'
|
|
18
|
+
{
|
|
19
|
+
"mode": "on",
|
|
20
|
+
"interval_minutes": 10
|
|
21
|
+
}
|
|
22
|
+
EOF
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get_field() {
|
|
26
|
+
ensure_state
|
|
27
|
+
STATE_PATH="$STATE_FILE" KEY="$1" python3 -c '
|
|
28
|
+
import json, os
|
|
29
|
+
d = json.load(open(os.environ["STATE_PATH"]))
|
|
30
|
+
print(d.get(os.environ["KEY"], ""))
|
|
31
|
+
'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
set_field() {
|
|
35
|
+
ensure_state
|
|
36
|
+
STATE_PATH="$STATE_FILE" KEY="$1" VAL="$2" python3 -c '
|
|
37
|
+
import json, os
|
|
38
|
+
p = os.environ["STATE_PATH"]
|
|
39
|
+
d = json.load(open(p))
|
|
40
|
+
key = os.environ["KEY"]
|
|
41
|
+
val = os.environ["VAL"]
|
|
42
|
+
if key == "interval_minutes":
|
|
43
|
+
val = int(val)
|
|
44
|
+
d[key] = val
|
|
45
|
+
json.dump(d, open(p, "w"), indent=2)
|
|
46
|
+
'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ARG=$(printf '%s' "$1" | tr -d '[:space:]')
|
|
50
|
+
|
|
51
|
+
case "$ARG" in
|
|
52
|
+
on)
|
|
53
|
+
set_field mode on
|
|
54
|
+
echo "Auto-checkpoint: ON (every $(get_field interval_minutes) min, mechanical + semantic)"
|
|
55
|
+
;;
|
|
56
|
+
off)
|
|
57
|
+
set_field mode off
|
|
58
|
+
echo "Auto-checkpoint: OFF"
|
|
59
|
+
;;
|
|
60
|
+
""|status)
|
|
61
|
+
ensure_state
|
|
62
|
+
echo "Auto-checkpoint: $(get_field mode) (every $(get_field interval_minutes) min)"
|
|
63
|
+
;;
|
|
64
|
+
*)
|
|
65
|
+
if printf '%s' "$ARG" | grep -qE '^[0-9]+$' && [ "$ARG" -ge 1 ] && [ "$ARG" -le 1440 ]; then
|
|
66
|
+
set_field interval_minutes "$ARG"
|
|
67
|
+
echo "Auto-checkpoint interval: $ARG min (mode: $(get_field mode))"
|
|
68
|
+
else
|
|
69
|
+
echo "Usage: /auto-checkpoint [on | off | <minutes 1-1440> | status]"
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
;;
|
|
73
|
+
esac
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UV Suite Hook: Tier A auto-checkpoint — mechanical state snapshot.
|
|
3
|
+
# Event: PostToolUse
|
|
4
|
+
#
|
|
5
|
+
# Logs every tool call into a per-session activity log. When `interval_minutes`
|
|
6
|
+
# have passed since the last mechanical checkpoint AND there has been activity
|
|
7
|
+
# in the interval, writes a deterministic snapshot to
|
|
8
|
+
# uv-out/checkpoints/<sid>/auto-<ts>-mechanical.md and forwards an
|
|
9
|
+
# AutoCheckpoint event to the watchtower.
|
|
10
|
+
#
|
|
11
|
+
# Tier B (semantic, claude -p) runs separately from the watchtower's timer.
|
|
12
|
+
|
|
13
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
14
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
15
|
+
STATE_DIR="$PROJECT_DIR/.uv-suite-state"
|
|
16
|
+
SESSIONS_DIR="$STATE_DIR/sessions"
|
|
17
|
+
mkdir -p "$SESSIONS_DIR" 2>/dev/null
|
|
18
|
+
|
|
19
|
+
STATE_FILE="$STATE_DIR/auto-checkpoint.json"
|
|
20
|
+
|
|
21
|
+
# Default state if missing
|
|
22
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
23
|
+
cat > "$STATE_FILE" <<'EOF'
|
|
24
|
+
{
|
|
25
|
+
"mode": "on",
|
|
26
|
+
"interval_minutes": 10
|
|
27
|
+
}
|
|
28
|
+
EOF
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Read mode + interval (skip if mode != on)
|
|
32
|
+
read_state() {
|
|
33
|
+
STATE_PATH="$STATE_FILE" python3 -c '
|
|
34
|
+
import json, os, sys
|
|
35
|
+
try:
|
|
36
|
+
d = json.load(open(os.environ["STATE_PATH"]))
|
|
37
|
+
print(d.get("mode", "on"))
|
|
38
|
+
print(d.get("interval_minutes", 10))
|
|
39
|
+
except Exception:
|
|
40
|
+
print("on"); print("10")
|
|
41
|
+
' 2>/dev/null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
STATE_OUT=$(read_state)
|
|
45
|
+
MODE=$(echo "$STATE_OUT" | sed -n 1p)
|
|
46
|
+
INTERVAL_MIN=$(echo "$STATE_OUT" | sed -n 2p)
|
|
47
|
+
[ "$MODE" != "on" ] && exit 0
|
|
48
|
+
[ -z "$INTERVAL_MIN" ] && INTERVAL_MIN=10
|
|
49
|
+
|
|
50
|
+
# Resolve session id
|
|
51
|
+
SID="${UVS_SESSION_ID:-}"
|
|
52
|
+
if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
|
|
53
|
+
SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
|
|
54
|
+
fi
|
|
55
|
+
[ -z "$SID" ] && exit 0
|
|
56
|
+
|
|
57
|
+
ACTIVITY_LOG="$SESSIONS_DIR/$SID.activity.log"
|
|
58
|
+
LAST_CP_FILE="$SESSIONS_DIR/$SID.last-mechanical-checkpoint.txt"
|
|
59
|
+
NOW=$(date +%s)
|
|
60
|
+
|
|
61
|
+
# Append activity entry. Cap log at last 500 lines.
|
|
62
|
+
TOOL=""
|
|
63
|
+
TARGET=""
|
|
64
|
+
if command -v jq >/dev/null 2>&1; then
|
|
65
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
66
|
+
TARGET=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // .tool_input.pattern // .tool_input.url // ""' 2>/dev/null)
|
|
67
|
+
fi
|
|
68
|
+
TARGET=$(printf '%s' "$TARGET" | tr -d '\n' | cut -c1-120)
|
|
69
|
+
[ -n "$TOOL" ] && echo "$NOW $TOOL $TARGET" >> "$ACTIVITY_LOG"
|
|
70
|
+
if [ -f "$ACTIVITY_LOG" ]; then
|
|
71
|
+
LINES=$(wc -l < "$ACTIVITY_LOG" 2>/dev/null | tr -d ' ')
|
|
72
|
+
if [ -n "$LINES" ] && [ "$LINES" -gt 500 ]; then
|
|
73
|
+
tail -n 500 "$ACTIVITY_LOG" > "$ACTIVITY_LOG.tmp" && mv "$ACTIVITY_LOG.tmp" "$ACTIVITY_LOG"
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Has the interval passed since last mechanical checkpoint?
|
|
78
|
+
# On the very first run, seed the timestamp with NOW so we don't fire a
|
|
79
|
+
# checkpoint immediately — the first one fires after the interval has elapsed.
|
|
80
|
+
if [ ! -f "$LAST_CP_FILE" ]; then
|
|
81
|
+
echo "$NOW" > "$LAST_CP_FILE"
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
LAST_CP=$(cat "$LAST_CP_FILE" 2>/dev/null)
|
|
85
|
+
LAST_CP=${LAST_CP:-0}
|
|
86
|
+
ELAPSED=$((NOW - LAST_CP))
|
|
87
|
+
INTERVAL_SEC=$((INTERVAL_MIN * 60))
|
|
88
|
+
[ "$ELAPSED" -lt "$INTERVAL_SEC" ] && exit 0
|
|
89
|
+
|
|
90
|
+
# Activity since last checkpoint? If none, skip.
|
|
91
|
+
SINCE_LINES=0
|
|
92
|
+
if [ -f "$ACTIVITY_LOG" ]; then
|
|
93
|
+
SINCE_LINES=$(awk -v cutoff="$LAST_CP" '$1 > cutoff' "$ACTIVITY_LOG" 2>/dev/null | wc -l | tr -d ' ')
|
|
94
|
+
fi
|
|
95
|
+
[ "${SINCE_LINES:-0}" -lt 1 ] && exit 0
|
|
96
|
+
|
|
97
|
+
# Resolve checkpoint dir + metadata via the existing helper
|
|
98
|
+
CP_DIR=$(CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$PROJECT_DIR/.claude/hooks/checkpoint-helper.sh" dir 2>/dev/null)
|
|
99
|
+
[ -z "$CP_DIR" ] && CP_DIR="$PROJECT_DIR/uv-out/checkpoints/$SID" && mkdir -p "$CP_DIR"
|
|
100
|
+
|
|
101
|
+
# Build the mechanical checkpoint body
|
|
102
|
+
TS_FILE=$(date +%Y-%m-%d-%H%M)
|
|
103
|
+
TS_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
104
|
+
CP_FILE="$CP_DIR/auto-$TS_FILE-mechanical.md"
|
|
105
|
+
|
|
106
|
+
# Frontmatter from helper, augmented with checkpoint_kind
|
|
107
|
+
FRONT=$(CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$PROJECT_DIR/.claude/hooks/checkpoint-helper.sh" frontmatter 2>/dev/null)
|
|
108
|
+
# Insert checkpoint_kind: auto-mechanical before closing ---
|
|
109
|
+
FRONT=$(printf '%s' "$FRONT" | awk '
|
|
110
|
+
/^---$/ { count++ }
|
|
111
|
+
count == 2 && !done { print "checkpoint_kind: auto-mechanical"; done=1 }
|
|
112
|
+
{ print }
|
|
113
|
+
')
|
|
114
|
+
|
|
115
|
+
# Activity summary from the log
|
|
116
|
+
ACTIVITY_SUMMARY=$(awk -v cutoff="$LAST_CP" '$1 > cutoff' "$ACTIVITY_LOG" 2>/dev/null | python3 -c '
|
|
117
|
+
import sys, collections, os
|
|
118
|
+
lines = [l.strip() for l in sys.stdin if l.strip()]
|
|
119
|
+
tools = collections.Counter()
|
|
120
|
+
files = collections.Counter()
|
|
121
|
+
for ln in lines:
|
|
122
|
+
parts = ln.split(" ", 2)
|
|
123
|
+
if len(parts) < 2: continue
|
|
124
|
+
tool = parts[1]
|
|
125
|
+
target = parts[2] if len(parts) > 2 else ""
|
|
126
|
+
tools[tool] += 1
|
|
127
|
+
if target and tool in ("Edit", "Write", "Read"):
|
|
128
|
+
files[target] += 1
|
|
129
|
+
print("### Tool calls")
|
|
130
|
+
for t, n in tools.most_common(8):
|
|
131
|
+
print(f"- {n}× {t}")
|
|
132
|
+
if files:
|
|
133
|
+
print("\n### Files touched")
|
|
134
|
+
for f, n in files.most_common(8):
|
|
135
|
+
print(f"- {f} ({n})")
|
|
136
|
+
print(f"\n_total tool calls in window: {sum(tools.values())}_")
|
|
137
|
+
' 2>/dev/null)
|
|
138
|
+
|
|
139
|
+
# Git state (best-effort, may be absent in non-git dirs)
|
|
140
|
+
GIT_BRANCH=$(cd "$PROJECT_DIR" && git branch --show-current 2>/dev/null)
|
|
141
|
+
GIT_STATUS=$(cd "$PROJECT_DIR" && git status --short 2>/dev/null | head -20)
|
|
142
|
+
GIT_LOG=$(cd "$PROJECT_DIR" && git log --oneline -5 2>/dev/null)
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
printf '%s\n\n' "$FRONT"
|
|
146
|
+
printf '# Auto-checkpoint (mechanical): %s\n\n' "$TS_ISO"
|
|
147
|
+
printf '_window: last %s min, %s tool calls_\n\n' "$INTERVAL_MIN" "$SINCE_LINES"
|
|
148
|
+
printf '## Activity\n\n%s\n\n' "$ACTIVITY_SUMMARY"
|
|
149
|
+
if [ -n "$GIT_BRANCH" ]; then
|
|
150
|
+
printf '## Git\n\n'
|
|
151
|
+
printf '**Branch:** %s\n\n' "$GIT_BRANCH"
|
|
152
|
+
if [ -n "$GIT_STATUS" ]; then
|
|
153
|
+
printf '**Status:**\n```\n%s\n```\n\n' "$GIT_STATUS"
|
|
154
|
+
else
|
|
155
|
+
printf '**Status:** clean\n\n'
|
|
156
|
+
fi
|
|
157
|
+
if [ -n "$GIT_LOG" ]; then
|
|
158
|
+
printf '**Recent commits:**\n```\n%s\n```\n' "$GIT_LOG"
|
|
159
|
+
fi
|
|
160
|
+
fi
|
|
161
|
+
} > "$CP_FILE"
|
|
162
|
+
|
|
163
|
+
# Update latest.md so /restore picks it up
|
|
164
|
+
cp "$CP_FILE" "$CP_DIR/latest.md" 2>/dev/null
|
|
165
|
+
echo "$NOW" > "$LAST_CP_FILE"
|
|
166
|
+
|
|
167
|
+
# Send AutoCheckpoint event to watchtower with inline content
|
|
168
|
+
if [ -x "$PROJECT_DIR/.claude/hooks/watchtower-send.sh" ]; then
|
|
169
|
+
PREVIEW=$(head -c 2000 "$CP_FILE")
|
|
170
|
+
PAYLOAD=$(PREVIEW_PATH="$CP_FILE" PREVIEW="$PREVIEW" INTERVAL_MIN="$INTERVAL_MIN" SINCE_LINES="$SINCE_LINES" CWD_VAR="$PROJECT_DIR" python3 -c '
|
|
171
|
+
import json, os
|
|
172
|
+
print(json.dumps({
|
|
173
|
+
"checkpoint_kind": "auto-mechanical",
|
|
174
|
+
"checkpoint_path": os.environ["PREVIEW_PATH"],
|
|
175
|
+
"checkpoint_preview": os.environ["PREVIEW"],
|
|
176
|
+
"interval_minutes": int(os.environ["INTERVAL_MIN"]),
|
|
177
|
+
"tool_calls_in_window": int(os.environ["SINCE_LINES"]),
|
|
178
|
+
"cwd": os.environ["CWD_VAR"],
|
|
179
|
+
}))
|
|
180
|
+
' 2>/dev/null)
|
|
181
|
+
printf '%s' "$PAYLOAD" | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$PROJECT_DIR/.claude/hooks/watchtower-send.sh" AutoCheckpoint 2>/dev/null
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
exit 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UV Suite helper: toggle confirm-mode or change the threshold.
|
|
3
|
+
# Used by the /confirm slash command. Extracted into a script so the
|
|
4
|
+
# slash command can avoid inline ${...} expansions, which Claude Code's
|
|
5
|
+
# permission heuristic flags as obfuscation.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# confirm-helper.sh # show status
|
|
9
|
+
# confirm-helper.sh status
|
|
10
|
+
# confirm-helper.sh on
|
|
11
|
+
# confirm-helper.sh off
|
|
12
|
+
# confirm-helper.sh <number> # set threshold
|
|
13
|
+
|
|
14
|
+
STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
|
|
15
|
+
mkdir -p "$STATE_DIR"
|
|
16
|
+
MODE_FILE="$STATE_DIR/confirm-mode.txt"
|
|
17
|
+
THRESH_FILE="$STATE_DIR/confirm-threshold.txt"
|
|
18
|
+
|
|
19
|
+
current_mode() { cat "$MODE_FILE" 2>/dev/null || echo "on"; }
|
|
20
|
+
current_thresh() { cat "$THRESH_FILE" 2>/dev/null || echo "50"; }
|
|
21
|
+
|
|
22
|
+
ARG=$(printf '%s' "$1" | tr -d '[:space:]')
|
|
23
|
+
|
|
24
|
+
case "$ARG" in
|
|
25
|
+
on)
|
|
26
|
+
echo "on" > "$MODE_FILE"
|
|
27
|
+
echo "Confirm mode: ON (threshold: $(current_thresh) words)"
|
|
28
|
+
;;
|
|
29
|
+
off)
|
|
30
|
+
echo "off" > "$MODE_FILE"
|
|
31
|
+
echo "Confirm mode: OFF"
|
|
32
|
+
;;
|
|
33
|
+
""|status)
|
|
34
|
+
echo "Confirm mode: $(current_mode) (threshold: $(current_thresh) words)"
|
|
35
|
+
;;
|
|
36
|
+
*)
|
|
37
|
+
if printf '%s' "$ARG" | grep -qE '^[0-9]+$'; then
|
|
38
|
+
echo "$ARG" > "$THRESH_FILE"
|
|
39
|
+
echo "Threshold set to $ARG words (mode: $(current_mode))"
|
|
40
|
+
else
|
|
41
|
+
echo "Usage: /confirm [on | off | <number> | status]"
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
;;
|
|
45
|
+
esac
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uv-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
|
|
5
5
|
"author": "Utsav Anand",
|
|
6
6
|
"license": "MIT",
|
package/personas/auto.json
CHANGED
|
@@ -37,14 +37,22 @@
|
|
|
37
37
|
"Bash(wc *)",
|
|
38
38
|
"Bash(head *)",
|
|
39
39
|
"Bash(tail *)",
|
|
40
|
-
"Bash(curl *)"
|
|
40
|
+
"Bash(curl *)",
|
|
41
|
+
"Bash(chmod *)",
|
|
42
|
+
"Bash(rm /tmp/*)",
|
|
43
|
+
"Bash(echo *)",
|
|
44
|
+
"Bash(printf *)",
|
|
45
|
+
"Bash(node --check *)",
|
|
46
|
+
"Bash(bash -n *)"
|
|
41
47
|
],
|
|
42
48
|
"deny": [
|
|
43
49
|
"Bash(rm -rf /)",
|
|
44
50
|
"Bash(rm -rf ~)",
|
|
45
51
|
"Bash(sudo rm -rf *)",
|
|
46
52
|
"Bash(git push --force * main)",
|
|
47
|
-
"Bash(git push --force * master)"
|
|
53
|
+
"Bash(git push --force * master)",
|
|
54
|
+
"Bash(rm -rf .)",
|
|
55
|
+
"Bash(sudo rm *)"
|
|
48
56
|
]
|
|
49
57
|
},
|
|
50
58
|
"hooks": {
|
|
@@ -155,6 +163,12 @@
|
|
|
155
163
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
|
|
156
164
|
"timeout": 2,
|
|
157
165
|
"async": true
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"type": "command",
|
|
169
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
|
|
170
|
+
"timeout": 5,
|
|
171
|
+
"async": true
|
|
158
172
|
}
|
|
159
173
|
]
|
|
160
174
|
},
|
|
@@ -33,7 +33,16 @@
|
|
|
33
33
|
"Bash(npx prettier *)",
|
|
34
34
|
"Bash(pytest *)",
|
|
35
35
|
"Bash(go test *)",
|
|
36
|
-
"Bash(cargo test *)"
|
|
36
|
+
"Bash(cargo test *)",
|
|
37
|
+
"Bash(chmod *)",
|
|
38
|
+
"Bash(mkdir *)",
|
|
39
|
+
"Bash(rm /tmp/*)",
|
|
40
|
+
"Bash(cat *)",
|
|
41
|
+
"Bash(ls *)",
|
|
42
|
+
"Bash(echo *)",
|
|
43
|
+
"Bash(printf *)",
|
|
44
|
+
"Bash(node --check *)",
|
|
45
|
+
"Bash(bash -n *)"
|
|
37
46
|
],
|
|
38
47
|
"deny": [
|
|
39
48
|
"Bash(rm -rf /)",
|
|
@@ -42,7 +51,8 @@
|
|
|
42
51
|
"Bash(sudo rm *)",
|
|
43
52
|
"Bash(git push --force * main)",
|
|
44
53
|
"Bash(git push --force * master)",
|
|
45
|
-
"Bash(git reset --hard origin/*)"
|
|
54
|
+
"Bash(git reset --hard origin/*)",
|
|
55
|
+
"Bash(sudo rm -rf *)"
|
|
46
56
|
]
|
|
47
57
|
},
|
|
48
58
|
"hooks": {
|
|
@@ -164,6 +174,12 @@
|
|
|
164
174
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
|
|
165
175
|
"timeout": 2,
|
|
166
176
|
"async": true
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"type": "command",
|
|
180
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
|
|
181
|
+
"timeout": 5,
|
|
182
|
+
"async": true
|
|
167
183
|
}
|
|
168
184
|
]
|
|
169
185
|
},
|
package/personas/spike.json
CHANGED
|
@@ -22,15 +22,30 @@
|
|
|
22
22
|
"Bash(find *)",
|
|
23
23
|
"Bash(wc *)",
|
|
24
24
|
"Bash(head *)",
|
|
25
|
-
"Bash(tail *)"
|
|
25
|
+
"Bash(tail *)",
|
|
26
|
+
"Edit(*)",
|
|
27
|
+
"Bash(git *)",
|
|
28
|
+
"Bash(npx prettier *)",
|
|
29
|
+
"Bash(npx markdown-* *)",
|
|
30
|
+
"Bash(curl *)",
|
|
31
|
+
"Bash(chmod *)",
|
|
32
|
+
"Bash(mkdir *)",
|
|
33
|
+
"Bash(rm /tmp/*)",
|
|
34
|
+
"Bash(cat *)",
|
|
35
|
+
"Bash(ls *)",
|
|
36
|
+
"Bash(echo *)",
|
|
37
|
+
"Bash(printf *)",
|
|
38
|
+
"Bash(node --check *)",
|
|
39
|
+
"Bash(bash -n *)"
|
|
26
40
|
],
|
|
27
41
|
"deny": [
|
|
28
|
-
"
|
|
29
|
-
"Bash(
|
|
30
|
-
"Bash(
|
|
31
|
-
"Bash(rm *)",
|
|
32
|
-
"Bash(
|
|
33
|
-
"Bash(
|
|
42
|
+
"Bash(rm -rf /)",
|
|
43
|
+
"Bash(rm -rf ~)",
|
|
44
|
+
"Bash(rm -rf .)",
|
|
45
|
+
"Bash(sudo rm *)",
|
|
46
|
+
"Bash(sudo rm -rf *)",
|
|
47
|
+
"Bash(git push --force * main)",
|
|
48
|
+
"Bash(git push --force * master)"
|
|
34
49
|
]
|
|
35
50
|
},
|
|
36
51
|
"hooks": {
|
|
@@ -123,6 +138,12 @@
|
|
|
123
138
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
|
|
124
139
|
"timeout": 2,
|
|
125
140
|
"async": true
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"type": "command",
|
|
144
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
|
|
145
|
+
"timeout": 5,
|
|
146
|
+
"async": true
|
|
126
147
|
}
|
|
127
148
|
]
|
|
128
149
|
},
|
package/personas/sport.json
CHANGED
|
@@ -16,7 +16,25 @@
|
|
|
16
16
|
"Bash(npm *)",
|
|
17
17
|
"Bash(npx *)",
|
|
18
18
|
"Bash(node *)",
|
|
19
|
-
"Bash(git *)"
|
|
19
|
+
"Bash(git *)",
|
|
20
|
+
"Bash(chmod *)",
|
|
21
|
+
"Bash(mkdir *)",
|
|
22
|
+
"Bash(rm /tmp/*)",
|
|
23
|
+
"Bash(cat *)",
|
|
24
|
+
"Bash(ls *)",
|
|
25
|
+
"Bash(echo *)",
|
|
26
|
+
"Bash(printf *)",
|
|
27
|
+
"Bash(node --check *)",
|
|
28
|
+
"Bash(bash -n *)"
|
|
29
|
+
],
|
|
30
|
+
"deny": [
|
|
31
|
+
"Bash(rm -rf /)",
|
|
32
|
+
"Bash(rm -rf ~)",
|
|
33
|
+
"Bash(rm -rf .)",
|
|
34
|
+
"Bash(sudo rm *)",
|
|
35
|
+
"Bash(sudo rm -rf *)",
|
|
36
|
+
"Bash(git push --force * main)",
|
|
37
|
+
"Bash(git push --force * master)"
|
|
20
38
|
]
|
|
21
39
|
},
|
|
22
40
|
"hooks": {
|
|
@@ -109,6 +127,12 @@
|
|
|
109
127
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
|
|
110
128
|
"timeout": 2,
|
|
111
129
|
"async": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"type": "command",
|
|
133
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
|
|
134
|
+
"timeout": 5,
|
|
135
|
+
"async": true
|
|
112
136
|
}
|
|
113
137
|
]
|
|
114
138
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: auto-checkpoint
|
|
3
|
+
description: >
|
|
4
|
+
Toggle automatic checkpoints, or change how often they fire. When on,
|
|
5
|
+
UV Suite writes a checkpoint every N minutes (default 10): a mechanical
|
|
6
|
+
state snapshot from the PostToolUse hook, plus a semantic summary written
|
|
7
|
+
by Claude (via `claude -p --bare --model haiku`) from the Watchtower's
|
|
8
|
+
timer. Sessions with no activity in the window are skipped.
|
|
9
|
+
argument-hint: "[on|off|<minutes>|status]"
|
|
10
|
+
user-invocable: true
|
|
11
|
+
allowed-tools:
|
|
12
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/auto-checkpoint-helper.sh *)
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Apply /auto-checkpoint $ARGUMENTS
|
|
16
|
+
|
|
17
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/auto-checkpoint-helper.sh $ARGUMENTS`
|
|
18
|
+
|
|
19
|
+
## Instructions
|
|
20
|
+
|
|
21
|
+
Show the user the line of output above as the response — it confirms what
|
|
22
|
+
changed. Do not add commentary. The change applies to the very next interval;
|
|
23
|
+
no restart needed.
|
|
24
|
+
|
|
25
|
+
## What this controls
|
|
26
|
+
|
|
27
|
+
- `on` / `off` — enable or disable both tiers (mechanical + semantic).
|
|
28
|
+
- `<minutes>` — set the checkpoint interval (1-1440). Default 10.
|
|
29
|
+
- `status` (or no argument) — print the current mode and interval.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
- **Tier A (mechanical):** the `auto-checkpoint.sh` hook runs after each tool
|
|
34
|
+
call. When the interval has passed and there's been activity since the last
|
|
35
|
+
checkpoint, it writes a deterministic snapshot — git state, recent tool calls,
|
|
36
|
+
files touched — to `uv-out/checkpoints/<sid>/auto-<ts>-mechanical.md`.
|
|
37
|
+
- **Tier B (semantic):** the Watchtower process (`uvs watch`) keeps a timer.
|
|
38
|
+
Every N minutes, for each active session, it shells out to `claude -p --bare
|
|
39
|
+
--model haiku` with a prompt assembled from the recent dashboard events and
|
|
40
|
+
git state. Output is saved next to the mechanical checkpoint as
|
|
41
|
+
`auto-<ts>-semantic.md`. Cap: `--max-budget-usd 0.05` per call.
|
|
42
|
+
- Both tiers fire `AutoCheckpoint` events to the Watchtower so they show up as
|
|
43
|
+
distinct rows on the dashboard.
|
|
44
|
+
- Sessions with zero activity in the interval are skipped — no empty checkpoints.
|
|
45
|
+
|
|
46
|
+
State lives in `.uv-suite-state/auto-checkpoint.json` (mode + interval) and
|
|
47
|
+
`.uv-suite-state/sessions/<sid>.last-{mechanical,semantic}-checkpoint.txt`.
|
package/skills/confirm/SKILL.md
CHANGED
|
@@ -7,15 +7,12 @@ description: >
|
|
|
7
7
|
argument-hint: "[on|off|<number>|status]"
|
|
8
8
|
user-invocable: true
|
|
9
9
|
allowed-tools:
|
|
10
|
-
- Bash(
|
|
11
|
-
- Bash(echo *)
|
|
12
|
-
- Bash(cat *)
|
|
13
|
-
- Bash(printf *)
|
|
10
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/confirm-helper.sh *)
|
|
14
11
|
---
|
|
15
12
|
|
|
16
13
|
## Apply /confirm $ARGUMENTS
|
|
17
14
|
|
|
18
|
-
!`
|
|
15
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/confirm-helper.sh $ARGUMENTS`
|
|
19
16
|
|
|
20
17
|
## Instructions
|
|
21
18
|
|
|
@@ -31,5 +28,5 @@ next user prompt; no restart needed.
|
|
|
31
28
|
step. Slash commands (`/foo ...`) are always exempt.
|
|
32
29
|
- `status` (or no argument) — print the current mode and threshold.
|
|
33
30
|
|
|
34
|
-
State lives in
|
|
35
|
-
|
|
31
|
+
State lives in `.uv-suite-state/confirm-mode.txt` and `.uv-suite-state/confirm-threshold.txt`
|
|
32
|
+
under `$CLAUDE_PROJECT_DIR`. Defaults if missing: mode `on`, threshold `50`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
You are writing an auto-checkpoint for a UV Suite coding session. Be specific
|
|
2
|
+
and tight — this is a state snapshot, not a narrative. Don't speculate beyond
|
|
3
|
+
what the events show.
|
|
4
|
+
|
|
5
|
+
## Session
|
|
6
|
+
|
|
7
|
+
- name: {{name}}
|
|
8
|
+
- kind: {{kind}}
|
|
9
|
+
- priority: {{priority}}
|
|
10
|
+
- persona: {{persona}}
|
|
11
|
+
- purpose: {{purpose}}
|
|
12
|
+
- elapsed since last semantic checkpoint: {{elapsed_min}} min
|
|
13
|
+
|
|
14
|
+
## Activity (last {{interval_min}} min, from the dashboard)
|
|
15
|
+
|
|
16
|
+
{{event_list}}
|
|
17
|
+
|
|
18
|
+
## Git
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
{{git_branch}}
|
|
22
|
+
{{git_status}}
|
|
23
|
+
{{git_log}}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Write the checkpoint
|
|
27
|
+
|
|
28
|
+
Output **only** the markdown body below — no preamble, no closing remarks.
|
|
29
|
+
Use this exact shape, max 30 lines total:
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
# Auto-checkpoint: {{timestamp}}
|
|
33
|
+
|
|
34
|
+
## Done in the last {{interval_min}} min
|
|
35
|
+
- 2-4 bullets — concrete: file edited, command run, decision visible in events.
|
|
36
|
+
Skip vague verbs ("worked on"); name the artifact and the change.
|
|
37
|
+
|
|
38
|
+
## Files touched
|
|
39
|
+
- list (omit section if empty)
|
|
40
|
+
|
|
41
|
+
## In progress
|
|
42
|
+
- 1-2 bullets — what the session appears to be working on right now, based
|
|
43
|
+
on the latest events.
|
|
44
|
+
|
|
45
|
+
## Notable
|
|
46
|
+
- only include this section if something stands out (a failure, a long pause
|
|
47
|
+
followed by a burst, a clear pattern shift). Otherwise omit the section.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If the activity log is sparse or ambiguous, say so plainly in "In progress"
|
|
51
|
+
rather than inventing details.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// UV Suite — Tier B auto-checkpoint runner.
|
|
2
|
+
// Called from watchtower/server.js on a setInterval. For each active session
|
|
3
|
+
// (one with at least one event in the last interval), shells out to
|
|
4
|
+
// `claude -p --bare --model haiku` to write a semantic summary.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawn } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const PROMPT_TEMPLATE_PATH = path.join(__dirname, "auto-checkpoint-prompt.md");
|
|
11
|
+
const DEFAULT_INTERVAL_MIN = 10;
|
|
12
|
+
const POLL_INTERVAL_MS = 60 * 1000; // wake up every 60s; per-session cadence is honored individually
|
|
13
|
+
const MAX_BUDGET_USD = "0.05";
|
|
14
|
+
const MODEL = "haiku";
|
|
15
|
+
|
|
16
|
+
function readJsonSafe(p) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readStringSafe(p) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readFileSync(p, "utf-8").trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Returns { mode, interval_minutes } — defaults if state file missing.
|
|
33
|
+
function readAutoCheckpointState(projectDir) {
|
|
34
|
+
const f = path.join(projectDir, ".uv-suite-state", "auto-checkpoint.json");
|
|
35
|
+
const d = readJsonSafe(f);
|
|
36
|
+
return {
|
|
37
|
+
mode: d?.mode ?? "on",
|
|
38
|
+
interval_minutes: d?.interval_minutes ?? DEFAULT_INTERVAL_MIN,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Group events by session, keep the most recent set per session.
|
|
43
|
+
function groupActiveSessions(events, windowMs) {
|
|
44
|
+
const cutoff = Date.now() - windowMs;
|
|
45
|
+
const bySession = new Map();
|
|
46
|
+
for (const ev of events) {
|
|
47
|
+
const sid = ev.uvs_session_id || ev.session_id;
|
|
48
|
+
if (!sid) continue;
|
|
49
|
+
if ((ev._ts || 0) < cutoff) continue;
|
|
50
|
+
if (!bySession.has(sid)) {
|
|
51
|
+
bySession.set(sid, {
|
|
52
|
+
sid,
|
|
53
|
+
cwd: ev.cwd,
|
|
54
|
+
session_name: ev.session_name || "",
|
|
55
|
+
session_kind: ev.session_kind || "",
|
|
56
|
+
session_priority: ev.session_priority || "",
|
|
57
|
+
session_purpose: ev.session_purpose || "",
|
|
58
|
+
persona: ev.persona || "",
|
|
59
|
+
events: [],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
bySession.get(sid).events.push(ev);
|
|
63
|
+
}
|
|
64
|
+
return [...bySession.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function eventToCompactLine(ev) {
|
|
68
|
+
const t = ev.event_type || ev.hook_event_name || "?";
|
|
69
|
+
const tool = ev.tool_name || "";
|
|
70
|
+
const input = ev.tool_input || {};
|
|
71
|
+
const target =
|
|
72
|
+
input.file_path ||
|
|
73
|
+
input.command ||
|
|
74
|
+
input.pattern ||
|
|
75
|
+
input.url ||
|
|
76
|
+
input.description ||
|
|
77
|
+
"";
|
|
78
|
+
const ts = new Date(ev._ts || Date.now()).toISOString().slice(11, 19);
|
|
79
|
+
let label = t;
|
|
80
|
+
if (tool) label += ` ${tool}`;
|
|
81
|
+
if (target) label += ` ${String(target).slice(0, 80)}`;
|
|
82
|
+
return ` ${ts} ${label}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function gitState(cwd) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const out = { branch: "", status: "", log: "" };
|
|
88
|
+
let pending = 3;
|
|
89
|
+
const done = () => {
|
|
90
|
+
if (--pending === 0) resolve(out);
|
|
91
|
+
};
|
|
92
|
+
const run = (args, key) => {
|
|
93
|
+
const child = spawn("git", args, { cwd });
|
|
94
|
+
let buf = "";
|
|
95
|
+
child.stdout.on("data", (d) => (buf += d));
|
|
96
|
+
child.on("close", () => {
|
|
97
|
+
out[key] = buf.trim();
|
|
98
|
+
done();
|
|
99
|
+
});
|
|
100
|
+
child.on("error", () => done());
|
|
101
|
+
};
|
|
102
|
+
run(["branch", "--show-current"], "branch");
|
|
103
|
+
run(["status", "--short"], "status");
|
|
104
|
+
run(["log", "--oneline", "-5"], "log");
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildPrompt(template, ctx) {
|
|
109
|
+
let out = template;
|
|
110
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
111
|
+
out = out.split(`{{${k}}}`).join(v ?? "");
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runClaudeP(prompt) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const child = spawn(
|
|
119
|
+
"claude",
|
|
120
|
+
["-p", "--bare", "--model", MODEL, "--max-budget-usd", MAX_BUDGET_USD],
|
|
121
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
122
|
+
);
|
|
123
|
+
let stdout = "";
|
|
124
|
+
let stderr = "";
|
|
125
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
126
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
127
|
+
child.on("error", (err) =>
|
|
128
|
+
resolve({ ok: false, stdout, stderr, error: err.message }),
|
|
129
|
+
);
|
|
130
|
+
child.on("close", (code) =>
|
|
131
|
+
resolve({ ok: code === 0, stdout, stderr, code }),
|
|
132
|
+
);
|
|
133
|
+
child.stdin.write(prompt);
|
|
134
|
+
child.stdin.end();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let promptTemplate = null;
|
|
139
|
+
function loadPromptTemplate() {
|
|
140
|
+
if (promptTemplate) return promptTemplate;
|
|
141
|
+
try {
|
|
142
|
+
promptTemplate = fs.readFileSync(PROMPT_TEMPLATE_PATH, "utf-8");
|
|
143
|
+
} catch {
|
|
144
|
+
promptTemplate = null;
|
|
145
|
+
}
|
|
146
|
+
return promptTemplate;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function processSession(session, broadcast) {
|
|
150
|
+
const { sid, cwd, events } = session;
|
|
151
|
+
if (!cwd || !sid) return;
|
|
152
|
+
|
|
153
|
+
const state = readAutoCheckpointState(cwd);
|
|
154
|
+
if (state.mode !== "on") return;
|
|
155
|
+
const intervalMs = state.interval_minutes * 60 * 1000;
|
|
156
|
+
|
|
157
|
+
const lastFile = path.join(
|
|
158
|
+
cwd,
|
|
159
|
+
".uv-suite-state",
|
|
160
|
+
"sessions",
|
|
161
|
+
`${sid}.last-semantic-checkpoint.txt`,
|
|
162
|
+
);
|
|
163
|
+
const lastTs = parseInt(readStringSafe(lastFile) || "0", 10);
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (now - lastTs * 1000 < intervalMs) return;
|
|
166
|
+
|
|
167
|
+
// Activity since last checkpoint
|
|
168
|
+
const recent = events
|
|
169
|
+
.filter((e) => (e._ts || 0) > lastTs * 1000)
|
|
170
|
+
.sort((a, b) => (a._ts || 0) - (b._ts || 0));
|
|
171
|
+
if (recent.length === 0) return;
|
|
172
|
+
|
|
173
|
+
const template = loadPromptTemplate();
|
|
174
|
+
if (!template) {
|
|
175
|
+
console.warn("[auto-checkpoint] prompt template missing; skipping");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const git = await gitState(cwd);
|
|
180
|
+
const eventList = recent.slice(-40).map(eventToCompactLine).join("\n");
|
|
181
|
+
const elapsedMin =
|
|
182
|
+
lastTs === 0
|
|
183
|
+
? state.interval_minutes
|
|
184
|
+
: Math.round((now - lastTs * 1000) / 60000);
|
|
185
|
+
|
|
186
|
+
const prompt = buildPrompt(template, {
|
|
187
|
+
name: session.session_name || "(unset)",
|
|
188
|
+
kind: session.session_kind || "(unset)",
|
|
189
|
+
priority: session.session_priority || "(unset)",
|
|
190
|
+
persona: session.persona || "(unset)",
|
|
191
|
+
purpose: session.session_purpose || "(unset)",
|
|
192
|
+
elapsed_min: String(elapsedMin),
|
|
193
|
+
interval_min: String(state.interval_minutes),
|
|
194
|
+
event_list: eventList,
|
|
195
|
+
git_branch: git.branch ? `Branch: ${git.branch}` : "(not a git repo)",
|
|
196
|
+
git_status: git.status || "(no changes)",
|
|
197
|
+
git_log: git.log || "",
|
|
198
|
+
timestamp: new Date(now).toISOString(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const result = await runClaudeP(prompt);
|
|
202
|
+
if (!result.ok || !result.stdout.trim()) {
|
|
203
|
+
console.warn(
|
|
204
|
+
`[auto-checkpoint] claude -p failed for ${sid.slice(0, 8)}:`,
|
|
205
|
+
result.error || result.stderr?.slice(0, 200) || `exit ${result.code}`,
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Write the checkpoint file
|
|
211
|
+
const cpDir = path.join(cwd, "uv-out", "checkpoints", sid);
|
|
212
|
+
fs.mkdirSync(cpDir, { recursive: true });
|
|
213
|
+
const tsFile = new Date(now)
|
|
214
|
+
.toISOString()
|
|
215
|
+
.slice(0, 16)
|
|
216
|
+
.replace(/[T:]/g, "-")
|
|
217
|
+
.replace(/-(\d\d)$/, "$1");
|
|
218
|
+
const cpFile = path.join(cpDir, `auto-${tsFile}-semantic.md`);
|
|
219
|
+
|
|
220
|
+
const frontmatter = [
|
|
221
|
+
"---",
|
|
222
|
+
`uvs_session_id: ${sid}`,
|
|
223
|
+
`session_name: ${session.session_name || ""}`,
|
|
224
|
+
`session_kind: ${session.session_kind || ""}`,
|
|
225
|
+
`session_purpose: ${session.session_purpose || ""}`,
|
|
226
|
+
`session_priority: ${session.session_priority || ""}`,
|
|
227
|
+
`persona: ${session.persona || ""}`,
|
|
228
|
+
`checkpoint_at: ${new Date(now).toISOString()}`,
|
|
229
|
+
`checkpoint_kind: auto-semantic`,
|
|
230
|
+
"---",
|
|
231
|
+
"",
|
|
232
|
+
].join("\n");
|
|
233
|
+
|
|
234
|
+
fs.writeFileSync(cpFile, frontmatter + result.stdout.trim() + "\n");
|
|
235
|
+
fs.writeFileSync(lastFile, String(Math.floor(now / 1000)));
|
|
236
|
+
|
|
237
|
+
// Broadcast as AutoCheckpoint event
|
|
238
|
+
const preview = (frontmatter + result.stdout).slice(0, 2000);
|
|
239
|
+
const event = {
|
|
240
|
+
event_type: "AutoCheckpoint",
|
|
241
|
+
source_app: path.basename(cwd),
|
|
242
|
+
cwd,
|
|
243
|
+
uvs_session_id: sid,
|
|
244
|
+
session_id: sid,
|
|
245
|
+
session_name: session.session_name,
|
|
246
|
+
session_kind: session.session_kind,
|
|
247
|
+
session_priority: session.session_priority,
|
|
248
|
+
persona: session.persona,
|
|
249
|
+
checkpoint_kind: "auto-semantic",
|
|
250
|
+
checkpoint_path: cpFile,
|
|
251
|
+
checkpoint_preview: preview,
|
|
252
|
+
interval_minutes: state.interval_minutes,
|
|
253
|
+
tool_calls_in_window: recent.length,
|
|
254
|
+
_ts: now,
|
|
255
|
+
};
|
|
256
|
+
broadcast(event);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// One pass over all sessions with recent activity. Exposed so tests (and
|
|
260
|
+
// any future "force a checkpoint now" command) can drive a single tick.
|
|
261
|
+
async function tick({ getEvents, broadcast }) {
|
|
262
|
+
try {
|
|
263
|
+
const events = getEvents();
|
|
264
|
+
const window = 60 * 60 * 1000; // 1h lookback for active sessions
|
|
265
|
+
const sessions = groupActiveSessions(events, window);
|
|
266
|
+
for (const s of sessions) {
|
|
267
|
+
await processSession(s, broadcast);
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.warn("[auto-checkpoint] tick error:", err.message);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Public API: start the runner. `getEvents` returns the watchtower's event
|
|
275
|
+
// store; `broadcast` injects an AutoCheckpoint event into the SSE stream.
|
|
276
|
+
// First tick after POLL_INTERVAL_MS; subsequent ticks every POLL_INTERVAL_MS.
|
|
277
|
+
function start({ getEvents, broadcast }) {
|
|
278
|
+
const handle = setInterval(
|
|
279
|
+
() => tick({ getEvents, broadcast }),
|
|
280
|
+
POLL_INTERVAL_MS,
|
|
281
|
+
);
|
|
282
|
+
return () => clearInterval(handle);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { start, tick };
|
|
@@ -357,6 +357,53 @@
|
|
|
357
357
|
.type-Notification { color: var(--warning); }
|
|
358
358
|
.type-PermissionRequest { color: var(--danger-soft); }
|
|
359
359
|
.type-PreCompact { color: var(--text-muted); }
|
|
360
|
+
.type-AutoCheckpoint { color: var(--success); font-weight: 700; }
|
|
361
|
+
|
|
362
|
+
/* Auto-checkpoint rows are full-width and expandable */
|
|
363
|
+
.event.checkpoint {
|
|
364
|
+
background: rgba(48, 209, 88, 0.06);
|
|
365
|
+
border-left: 3px solid var(--success);
|
|
366
|
+
padding-left: 25px;
|
|
367
|
+
opacity: 1;
|
|
368
|
+
}
|
|
369
|
+
.event.checkpoint .detail { cursor: pointer; }
|
|
370
|
+
.event.checkpoint .checkpoint-summary {
|
|
371
|
+
color: var(--text);
|
|
372
|
+
font-weight: 500;
|
|
373
|
+
}
|
|
374
|
+
.event.checkpoint .checkpoint-kind {
|
|
375
|
+
display: inline-block;
|
|
376
|
+
margin-left: 8px;
|
|
377
|
+
padding: 1px 6px;
|
|
378
|
+
font-size: 10.5px;
|
|
379
|
+
font-weight: 600;
|
|
380
|
+
letter-spacing: 0.04em;
|
|
381
|
+
text-transform: uppercase;
|
|
382
|
+
border-radius: 4px;
|
|
383
|
+
background: var(--success-soft);
|
|
384
|
+
color: var(--success);
|
|
385
|
+
vertical-align: 1px;
|
|
386
|
+
}
|
|
387
|
+
.event.checkpoint .checkpoint-body {
|
|
388
|
+
display: none;
|
|
389
|
+
margin-top: 10px;
|
|
390
|
+
padding: 12px 14px;
|
|
391
|
+
background: var(--surface);
|
|
392
|
+
border-radius: 6px;
|
|
393
|
+
color: var(--text-muted);
|
|
394
|
+
font-family: var(--font-mono);
|
|
395
|
+
font-size: 12.5px;
|
|
396
|
+
white-space: pre-wrap;
|
|
397
|
+
line-height: 1.55;
|
|
398
|
+
max-height: 360px;
|
|
399
|
+
overflow-y: auto;
|
|
400
|
+
}
|
|
401
|
+
.event.checkpoint.expanded .checkpoint-body { display: block; }
|
|
402
|
+
.event.checkpoint .checkpoint-toggle {
|
|
403
|
+
color: var(--text-dim);
|
|
404
|
+
font-size: 12px;
|
|
405
|
+
margin-left: 8px;
|
|
406
|
+
}
|
|
360
407
|
</style>
|
|
361
408
|
</head>
|
|
362
409
|
<body>
|
|
@@ -603,11 +650,13 @@ function renderEvent(ev) {
|
|
|
603
650
|
const fail = isFailure(ev);
|
|
604
651
|
const boundary = isSessionBoundary(ev);
|
|
605
652
|
const prompt = isUserPrompt(ev);
|
|
653
|
+
const checkpoint = type === 'AutoCheckpoint';
|
|
606
654
|
const priority = sessions[sid]?.priority || '';
|
|
607
655
|
|
|
608
656
|
const div = document.createElement('div');
|
|
609
657
|
let cls = 'event';
|
|
610
|
-
if (
|
|
658
|
+
if (checkpoint) cls += ' checkpoint';
|
|
659
|
+
else if (human) cls += ' needs-human';
|
|
611
660
|
else if (fail) cls += ' failure';
|
|
612
661
|
else if (prompt) cls += ' user-prompt';
|
|
613
662
|
else if (boundary) cls += ' session-boundary';
|
|
@@ -617,13 +666,32 @@ function renderEvent(ev) {
|
|
|
617
666
|
|
|
618
667
|
const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
|
|
619
668
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
669
|
+
if (checkpoint) {
|
|
670
|
+
const kind = ev.checkpoint_kind || 'auto';
|
|
671
|
+
const kindLabel = kind === 'auto-mechanical' ? 'mechanical' : kind === 'auto-semantic' ? 'semantic' : kind;
|
|
672
|
+
const calls = ev.tool_calls_in_window || 0;
|
|
673
|
+
const interval = ev.interval_minutes || '';
|
|
674
|
+
const summary = `<span class="checkpoint-summary">${escapeHtml(ev.checkpoint_path?.split('/').slice(-1)[0] || 'checkpoint')}</span>` +
|
|
675
|
+
`<span class="checkpoint-kind">${kindLabel}</span>` +
|
|
676
|
+
`<span class="checkpoint-toggle">${calls} tool calls in last ${interval}m · click to expand</span>`;
|
|
677
|
+
const body = ev.checkpoint_preview ? `<div class="checkpoint-body">${escapeHtml(ev.checkpoint_preview)}</div>` : '';
|
|
678
|
+
div.innerHTML = `
|
|
679
|
+
<span class="time">${formatTime(ev._ts)}</span>
|
|
680
|
+
<span class="type type-${type}">${type}</span>
|
|
681
|
+
<span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
|
|
682
|
+
<span class="tool"></span>
|
|
683
|
+
<span class="detail">${summary}${body}</span>
|
|
684
|
+
`;
|
|
685
|
+
div.querySelector('.detail').addEventListener('click', () => div.classList.toggle('expanded'));
|
|
686
|
+
} else {
|
|
687
|
+
div.innerHTML = `
|
|
688
|
+
<span class="time">${formatTime(ev._ts)}</span>
|
|
689
|
+
<span class="type type-${type}">${type}${humanBadge}</span>
|
|
690
|
+
<span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
|
|
691
|
+
<span class="tool">${tool}</span>
|
|
692
|
+
<span class="detail">${eventDetail(ev)}</span>
|
|
693
|
+
`;
|
|
694
|
+
}
|
|
627
695
|
|
|
628
696
|
div._ev = ev;
|
|
629
697
|
|
package/watchtower/server.js
CHANGED
|
@@ -8,6 +8,7 @@ const http = require("http");
|
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const crypto = require("crypto");
|
|
11
|
+
const autoCheckpointRunner = require("./auto-checkpoint-runner");
|
|
11
12
|
|
|
12
13
|
const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
|
|
13
14
|
const DATA_FILE = path.join(__dirname, "events.json");
|
|
@@ -176,4 +177,24 @@ server.listen(PORT, () => {
|
|
|
176
177
|
console.log(
|
|
177
178
|
`Waiting for hook events on POST http://localhost:${PORT}/events`,
|
|
178
179
|
);
|
|
180
|
+
|
|
181
|
+
// Tier B auto-checkpoint runner. Polls every minute, calls
|
|
182
|
+
// `claude -p --bare --model haiku` for each active session whose
|
|
183
|
+
// configured interval has elapsed. Disable with `/auto-checkpoint off`
|
|
184
|
+
// per project, or set UVS_AUTO_CHECKPOINT_DISABLED=1 to disable globally.
|
|
185
|
+
if (!process.env.UVS_AUTO_CHECKPOINT_DISABLED) {
|
|
186
|
+
autoCheckpointRunner.start({
|
|
187
|
+
getEvents: () => events,
|
|
188
|
+
broadcast: (ev) => {
|
|
189
|
+
ev._ts = ev._ts || Date.now();
|
|
190
|
+
ev._id = crypto.randomUUID();
|
|
191
|
+
events.push(ev);
|
|
192
|
+
broadcast(ev);
|
|
193
|
+
saveEvents();
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
console.log(
|
|
197
|
+
"Auto-checkpoint runner started (Tier B, polls every 60s, uses claude -p)",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
179
200
|
});
|