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.
@@ -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.26.5",
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",
@@ -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
  },
@@ -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
- "Edit(*)",
29
- "Bash(git commit *)",
30
- "Bash(git push *)",
31
- "Bash(rm *)",
32
- "Bash(npm run *)",
33
- "Bash(npx *)"
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
  },
@@ -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`.
@@ -7,15 +7,12 @@ description: >
7
7
  argument-hint: "[on|off|<number>|status]"
8
8
  user-invocable: true
9
9
  allowed-tools:
10
- - Bash(mkdir *)
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
- !`STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"; mkdir -p "$STATE_DIR"; MODE_FILE="$STATE_DIR/confirm-mode.txt"; THRESH_FILE="$STATE_DIR/confirm-threshold.txt"; ARG=$(printf '%s' "$ARGUMENTS" | tr -d '[:space:]'); current_mode() { cat "$MODE_FILE" 2>/dev/null || echo "on"; }; current_thresh() { cat "$THRESH_FILE" 2>/dev/null || echo "50"; }; case "$ARG" in on) echo "on" > "$MODE_FILE"; echo "Confirm mode: ON (threshold: $(current_thresh) words)";; off) echo "off" > "$MODE_FILE"; echo "Confirm mode: OFF";; ''|status) echo "Confirm mode: $(current_mode) (threshold: $(current_thresh) words)";; *) if printf '%s' "$ARG" | grep -qE '^[0-9]+$'; then echo "$ARG" > "$THRESH_FILE"; echo "Threshold set to $ARG words (mode: $(current_mode))"; else echo "Usage: /confirm [on | off | <number> | status]"; fi;; esac`
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 `${CLAUDE_PROJECT_DIR}/.uv-suite-state/confirm-mode.txt` and
35
- `confirm-threshold.txt`. Defaults if missing: mode `on`, threshold `50`.
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 (human) cls += ' needs-human';
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
- div.innerHTML = `
621
- <span class="time">${formatTime(ev._ts)}</span>
622
- <span class="type type-${type}">${type}${humanBadge}</span>
623
- <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
624
- <span class="tool">${tool}</span>
625
- <span class="detail">${eventDetail(ev)}</span>
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
 
@@ -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
  });