uv-suite 0.26.2 → 0.26.4
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/bin/cli.js +260 -133
- package/hooks/checkpoint-helper.sh +124 -0
- package/package.json +1 -1
- package/skills/checkpoint/SKILL.md +29 -16
- package/skills/restore/SKILL.md +37 -7
package/bin/cli.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const { execSync, spawn } = require(
|
|
4
|
-
const path = require(
|
|
5
|
-
const fs = require(
|
|
3
|
+
const { execSync, spawn } = require("child_process");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const readline = require("readline");
|
|
6
8
|
|
|
7
|
-
const UV_SUITE_DIR = path.resolve(__dirname,
|
|
9
|
+
const UV_SUITE_DIR = path.resolve(__dirname, "..");
|
|
8
10
|
const args = process.argv.slice(2);
|
|
9
11
|
const command = args[0];
|
|
10
|
-
const pkg = require(path.join(UV_SUITE_DIR,
|
|
12
|
+
const pkg = require(path.join(UV_SUITE_DIR, "package.json"));
|
|
11
13
|
|
|
12
|
-
const PERSONAS = [
|
|
13
|
-
const TOOLS = [
|
|
14
|
+
const PERSONAS = ["spike", "sport", "pro", "professional", "auto"];
|
|
15
|
+
const TOOLS = ["claude", "codex"];
|
|
14
16
|
|
|
15
17
|
function usage() {
|
|
16
18
|
console.log(`
|
|
@@ -57,208 +59,333 @@ function info() {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function install() {
|
|
60
|
-
const installScript = path.join(UV_SUITE_DIR,
|
|
62
|
+
const installScript = path.join(UV_SUITE_DIR, "install.sh");
|
|
61
63
|
if (!fs.existsSync(installScript)) {
|
|
62
|
-
console.error(
|
|
64
|
+
console.error("Error: install.sh not found at", installScript);
|
|
63
65
|
process.exit(1);
|
|
64
66
|
}
|
|
65
|
-
const installArgs = args.slice(1).join(
|
|
67
|
+
const installArgs = args.slice(1).join(" ");
|
|
66
68
|
try {
|
|
67
|
-
execSync(`bash "${installScript}" ${installArgs}`, { stdio:
|
|
69
|
+
execSync(`bash "${installScript}" ${installArgs}`, { stdio: "inherit" });
|
|
68
70
|
} catch (e) {
|
|
69
71
|
process.exit(e.status || 1);
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function normPersona(p) {
|
|
74
|
-
if (p ===
|
|
76
|
+
if (p === "pro" || p === "professional") return "professional";
|
|
75
77
|
if (PERSONAS.includes(p)) return p;
|
|
76
78
|
return null;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
function personaLabel(p) {
|
|
80
82
|
const labels = {
|
|
81
|
-
spike:
|
|
82
|
-
sport:
|
|
83
|
-
professional:
|
|
84
|
-
auto:
|
|
83
|
+
spike: "Spike — research & docs (Opus, max)",
|
|
84
|
+
sport: "Sport — lightweight (Sonnet, high)",
|
|
85
|
+
professional: "Professional — full rigor (all hooks, all guardrails)",
|
|
86
|
+
auto: "Auto — autonomous (max, everything approved)",
|
|
85
87
|
};
|
|
86
88
|
return labels[p] || p;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
91
|
+
// Sync package-owned files (hooks, skills, personas, agents, optional guardrails)
|
|
92
|
+
// from the installed npm package into the project's .claude/. Idempotent — runs
|
|
93
|
+
// every launch so users on older versions pick up new hooks and slash commands
|
|
94
|
+
// after `npm install -g uv-suite@latest` without needing `uvs install` again.
|
|
95
|
+
// settings.json is preserved if it exists (user customizations).
|
|
96
|
+
function syncPackageFiles(persona) {
|
|
97
|
+
const srcDir = UV_SUITE_DIR;
|
|
98
|
+
const targetDir = path.resolve(".claude");
|
|
99
|
+
const hooksDir = path.join(targetDir, "hooks");
|
|
100
|
+
const personasDir = path.join(targetDir, "personas");
|
|
101
|
+
const wasFreshInstall =
|
|
102
|
+
!fs.existsSync(personasDir) || !fs.existsSync(hooksDir);
|
|
103
|
+
|
|
104
|
+
for (const dir of ["agents", "skills", "hooks", "rules", "personas"]) {
|
|
105
|
+
fs.mkdirSync(path.join(targetDir, dir), { recursive: true });
|
|
106
|
+
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const agentsSrc = path.join(srcDir, "agents", "claude-code");
|
|
109
|
+
if (fs.existsSync(agentsSrc)) {
|
|
110
|
+
for (const f of fs.readdirSync(agentsSrc)) {
|
|
111
|
+
fs.copyFileSync(
|
|
112
|
+
path.join(agentsSrc, f),
|
|
113
|
+
path.join(targetDir, "agents", f),
|
|
114
|
+
);
|
|
113
115
|
}
|
|
116
|
+
}
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
fs.chmodSync(dest, 0o755);
|
|
122
|
-
}
|
|
118
|
+
const hooksSrc = path.join(srcDir, "hooks");
|
|
119
|
+
if (fs.existsSync(hooksSrc)) {
|
|
120
|
+
for (const f of fs.readdirSync(hooksSrc)) {
|
|
121
|
+
const dest = path.join(targetDir, "hooks", f);
|
|
122
|
+
fs.copyFileSync(path.join(hooksSrc, f), dest);
|
|
123
|
+
fs.chmodSync(dest, 0o755);
|
|
123
124
|
}
|
|
125
|
+
}
|
|
124
126
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
fs.copyFileSync(skillFile, path.join(destDir, 'SKILL.md'));
|
|
134
|
-
}
|
|
127
|
+
const skillsSrc = path.join(srcDir, "skills");
|
|
128
|
+
if (fs.existsSync(skillsSrc)) {
|
|
129
|
+
for (const d of fs.readdirSync(skillsSrc)) {
|
|
130
|
+
const skillFile = path.join(skillsSrc, d, "SKILL.md");
|
|
131
|
+
if (fs.existsSync(skillFile)) {
|
|
132
|
+
const destDir = path.join(targetDir, "skills", d);
|
|
133
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
134
|
+
fs.copyFileSync(skillFile, path.join(destDir, "SKILL.md"));
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
}
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
139
|
+
if (persona === "professional" || persona === "auto") {
|
|
140
|
+
const guardSrc = path.join(srcDir, "guardrails");
|
|
141
|
+
if (fs.existsSync(guardSrc)) {
|
|
142
|
+
for (const f of fs.readdirSync(guardSrc)) {
|
|
143
|
+
fs.copyFileSync(
|
|
144
|
+
path.join(guardSrc, f),
|
|
145
|
+
path.join(targetDir, "rules", f),
|
|
146
|
+
);
|
|
145
147
|
}
|
|
146
148
|
}
|
|
149
|
+
}
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
const personasSrc = path.join(srcDir, "personas");
|
|
152
|
+
if (fs.existsSync(personasSrc)) {
|
|
153
|
+
for (const f of fs.readdirSync(personasSrc)) {
|
|
154
|
+
fs.copyFileSync(
|
|
155
|
+
path.join(personasSrc, f),
|
|
156
|
+
path.join(targetDir, "personas", f),
|
|
157
|
+
);
|
|
154
158
|
}
|
|
159
|
+
}
|
|
155
160
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
// settings.json is user-owned. Only seed it on fresh install.
|
|
162
|
+
const personaFile = path.join(targetDir, "personas", `${persona}.json`);
|
|
163
|
+
const settingsFile = path.join(targetDir, "settings.json");
|
|
164
|
+
if (
|
|
165
|
+
wasFreshInstall &&
|
|
166
|
+
fs.existsSync(personaFile) &&
|
|
167
|
+
!fs.existsSync(settingsFile)
|
|
168
|
+
) {
|
|
169
|
+
fs.copyFileSync(personaFile, settingsFile);
|
|
170
|
+
}
|
|
162
171
|
|
|
172
|
+
if (wasFreshInstall) {
|
|
173
|
+
console.log(
|
|
174
|
+
"UV Suite not installed in this project. Installing core files...",
|
|
175
|
+
);
|
|
163
176
|
console.log(` Installed: agents, skills, hooks, guardrails, personas`);
|
|
164
|
-
console.log(
|
|
177
|
+
console.log("");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function prompt(rl, question) {
|
|
182
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeKind(s) {
|
|
186
|
+
const v = (s || "").toLowerCase().trim();
|
|
187
|
+
if (["l", "long", "long-running"].includes(v)) return "long-running";
|
|
188
|
+
if (["o", "outcome"].includes(v)) return "outcome";
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizePriority(s) {
|
|
193
|
+
const v = (s || "").toLowerCase().trim();
|
|
194
|
+
if (["l", "low"].includes(v)) return "low";
|
|
195
|
+
if (["m", "med", "medium"].includes(v)) return "med";
|
|
196
|
+
if (["h", "high"].includes(v)) return "high";
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Generate a UVS_SESSION_ID, prompt for metadata (name/kind/purpose/priority),
|
|
201
|
+
// write it to .uv-suite-state/sessions/<sid>.json, and return the id + name.
|
|
202
|
+
// Skipping (Enter) leaves a field empty; the session-label-nag.sh hook will
|
|
203
|
+
// remind the user to run /session-init mid-flight.
|
|
204
|
+
async function setupSession(persona) {
|
|
205
|
+
const projectDir = process.cwd();
|
|
206
|
+
const stateDir = path.join(projectDir, ".uv-suite-state");
|
|
207
|
+
const sessionsDir = path.join(stateDir, "sessions");
|
|
208
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
const sid = crypto.randomUUID();
|
|
211
|
+
let name = "";
|
|
212
|
+
let kind = "";
|
|
213
|
+
let purpose = "";
|
|
214
|
+
let priority = "";
|
|
215
|
+
|
|
216
|
+
if (process.stdin.isTTY && !process.env.UVS_NO_PROMPT) {
|
|
217
|
+
const rl = readline.createInterface({
|
|
218
|
+
input: process.stdin,
|
|
219
|
+
output: process.stdout,
|
|
220
|
+
});
|
|
221
|
+
console.log("");
|
|
222
|
+
console.log("Label this session (Enter to skip — you'll be reminded):");
|
|
223
|
+
name = (await prompt(rl, " name: ")).trim();
|
|
224
|
+
const kindRaw = await prompt(rl, " kind [long/outcome]: ");
|
|
225
|
+
purpose = (await prompt(rl, " purpose: ")).trim();
|
|
226
|
+
const priorityRaw = await prompt(rl, " priority [low/med/high]: ");
|
|
227
|
+
rl.close();
|
|
228
|
+
kind = normalizeKind(kindRaw);
|
|
229
|
+
priority = normalizePriority(priorityRaw);
|
|
165
230
|
}
|
|
231
|
+
|
|
232
|
+
const meta = {
|
|
233
|
+
uvs_session_id: sid,
|
|
234
|
+
name,
|
|
235
|
+
kind,
|
|
236
|
+
purpose,
|
|
237
|
+
priority,
|
|
238
|
+
persona,
|
|
239
|
+
cwd: projectDir,
|
|
240
|
+
started_at: Math.floor(Date.now() / 1000),
|
|
241
|
+
};
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
path.join(sessionsDir, `${sid}.json`),
|
|
244
|
+
JSON.stringify(meta, null, 2),
|
|
245
|
+
);
|
|
246
|
+
fs.writeFileSync(path.join(stateDir, "current-session.txt"), sid);
|
|
247
|
+
|
|
248
|
+
return { sid, name };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Backwards-compat shim — older code in this file still references this name.
|
|
252
|
+
function ensureInstalled(persona) {
|
|
253
|
+
syncPackageFiles(persona);
|
|
166
254
|
}
|
|
167
255
|
|
|
168
|
-
function launchClaude(persona, extra) {
|
|
169
|
-
|
|
170
|
-
const settings = path.resolve(
|
|
256
|
+
async function launchClaude(persona, extra) {
|
|
257
|
+
syncPackageFiles(persona);
|
|
258
|
+
const settings = path.resolve(".claude/personas", `${persona}.json`);
|
|
171
259
|
if (!fs.existsSync(settings)) {
|
|
172
|
-
console.error(
|
|
260
|
+
console.error(
|
|
261
|
+
`Error: installation failed. Run 'uvs install --persona ${persona}' manually.`,
|
|
262
|
+
);
|
|
173
263
|
process.exit(1);
|
|
174
264
|
}
|
|
265
|
+
const { sid, name } = await setupSession(persona);
|
|
266
|
+
console.log("");
|
|
175
267
|
console.log(`UV Suite | Claude Code | ${personaLabel(persona)}`);
|
|
176
|
-
console.log(
|
|
177
|
-
|
|
178
|
-
child
|
|
268
|
+
console.log(`Session: ${sid.slice(0, 8)}${name ? " — " + name : ""}`);
|
|
269
|
+
console.log("");
|
|
270
|
+
const child = spawn("claude", ["--settings", settings, ...extra], {
|
|
271
|
+
stdio: "inherit",
|
|
272
|
+
env: { ...process.env, UVS_SESSION_ID: sid },
|
|
273
|
+
});
|
|
274
|
+
child.on("exit", (code) => process.exit(code || 0));
|
|
179
275
|
}
|
|
180
276
|
|
|
181
|
-
function launchCodex(persona, extra) {
|
|
277
|
+
async function launchCodex(persona, extra) {
|
|
182
278
|
const approvalMap = {
|
|
183
|
-
spike: [
|
|
184
|
-
sport: [
|
|
185
|
-
professional: [
|
|
186
|
-
auto: [
|
|
279
|
+
spike: ["--model", "o3", "--approval-mode", "suggest"],
|
|
280
|
+
sport: ["--approval-mode", "auto-edit"],
|
|
281
|
+
professional: ["--approval-mode", "suggest"],
|
|
282
|
+
auto: ["--approval-mode", "full-auto"],
|
|
187
283
|
};
|
|
188
|
-
const codexArgs = approvalMap[persona] || [
|
|
284
|
+
const codexArgs = approvalMap[persona] || ["--approval-mode", "suggest"];
|
|
285
|
+
const { sid, name } = await setupSession(persona);
|
|
286
|
+
console.log("");
|
|
189
287
|
console.log(`UV Suite | Codex | ${personaLabel(persona)}`);
|
|
190
|
-
console.log(
|
|
191
|
-
|
|
192
|
-
child
|
|
288
|
+
console.log(`Session: ${sid.slice(0, 8)}${name ? " — " + name : ""}`);
|
|
289
|
+
console.log("");
|
|
290
|
+
const child = spawn("codex", [...codexArgs, ...extra], {
|
|
291
|
+
stdio: "inherit",
|
|
292
|
+
env: { ...process.env, UVS_SESSION_ID: sid },
|
|
293
|
+
});
|
|
294
|
+
child.on("exit", (code) => process.exit(code || 0));
|
|
193
295
|
}
|
|
194
296
|
|
|
195
297
|
function watch() {
|
|
196
|
-
const serverScript = path.join(UV_SUITE_DIR,
|
|
298
|
+
const serverScript = path.join(UV_SUITE_DIR, "watchtower", "server.js");
|
|
197
299
|
if (!fs.existsSync(serverScript)) {
|
|
198
|
-
console.error(
|
|
300
|
+
console.error("Error: watchtower server not found at", serverScript);
|
|
199
301
|
process.exit(1);
|
|
200
302
|
}
|
|
201
303
|
|
|
202
|
-
const bg = args.includes(
|
|
203
|
-
console.log(
|
|
204
|
-
console.log(
|
|
205
|
-
|
|
304
|
+
const bg = args.includes("--bg") || args.includes("--background");
|
|
305
|
+
console.log("UV Suite Watchtower starting...");
|
|
306
|
+
console.log(
|
|
307
|
+
"Dashboard: http://localhost:" + (process.env.UVS_WATCHTOWER_PORT || 4200),
|
|
308
|
+
);
|
|
309
|
+
console.log("");
|
|
206
310
|
|
|
207
311
|
if (bg) {
|
|
208
|
-
const child = spawn(
|
|
209
|
-
stdio:
|
|
312
|
+
const child = spawn("node", [serverScript], {
|
|
313
|
+
stdio: "ignore",
|
|
210
314
|
detached: true,
|
|
211
315
|
});
|
|
212
316
|
child.unref();
|
|
213
317
|
console.log(`Running in background (PID: ${child.pid})`);
|
|
214
|
-
console.log(
|
|
318
|
+
console.log("Stop with: kill " + child.pid);
|
|
215
319
|
|
|
216
320
|
// Open browser
|
|
217
|
-
const opener =
|
|
218
|
-
|
|
321
|
+
const opener =
|
|
322
|
+
process.platform === "darwin"
|
|
323
|
+
? "open"
|
|
324
|
+
: process.platform === "win32"
|
|
325
|
+
? "start"
|
|
326
|
+
: "xdg-open";
|
|
327
|
+
spawn(
|
|
328
|
+
opener,
|
|
329
|
+
["http://localhost:" + (process.env.UVS_WATCHTOWER_PORT || 4200)],
|
|
330
|
+
{ stdio: "ignore" },
|
|
331
|
+
);
|
|
219
332
|
} else {
|
|
220
333
|
// Foreground — open browser after a short delay
|
|
221
334
|
setTimeout(() => {
|
|
222
|
-
const opener =
|
|
223
|
-
|
|
335
|
+
const opener =
|
|
336
|
+
process.platform === "darwin"
|
|
337
|
+
? "open"
|
|
338
|
+
: process.platform === "win32"
|
|
339
|
+
? "start"
|
|
340
|
+
: "xdg-open";
|
|
341
|
+
spawn(
|
|
342
|
+
opener,
|
|
343
|
+
["http://localhost:" + (process.env.UVS_WATCHTOWER_PORT || 4200)],
|
|
344
|
+
{ stdio: "ignore" },
|
|
345
|
+
);
|
|
224
346
|
}, 1000);
|
|
225
347
|
|
|
226
|
-
const child = spawn(
|
|
227
|
-
child.on(
|
|
348
|
+
const child = spawn("node", [serverScript], { stdio: "inherit" });
|
|
349
|
+
child.on("exit", (code) => process.exit(code || 0));
|
|
228
350
|
}
|
|
229
351
|
}
|
|
230
352
|
|
|
231
353
|
// --- Parse and route ---
|
|
232
354
|
|
|
233
|
-
if (!command || command ===
|
|
355
|
+
if (!command || command === "--help" || command === "-h") {
|
|
234
356
|
usage();
|
|
235
357
|
process.exit(0);
|
|
236
358
|
}
|
|
237
359
|
|
|
238
|
-
|
|
239
|
-
watch
|
|
240
|
-
|
|
241
|
-
install
|
|
242
|
-
|
|
243
|
-
info
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
360
|
+
(async () => {
|
|
361
|
+
if (command === "watch") {
|
|
362
|
+
watch();
|
|
363
|
+
} else if (command === "install") {
|
|
364
|
+
install();
|
|
365
|
+
} else if (command === "info") {
|
|
366
|
+
info();
|
|
367
|
+
} else if (TOOLS.includes(command)) {
|
|
368
|
+
// uvs claude pro, uvs codex auto
|
|
369
|
+
const persona = normPersona(args[1] || "pro");
|
|
370
|
+
if (!persona) {
|
|
371
|
+
console.error(`Unknown persona: ${args[1]}`);
|
|
372
|
+
console.error("Available: spike, sport, pro, auto");
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
const extra = args.slice(2);
|
|
376
|
+
if (command === "claude") await launchClaude(persona, extra);
|
|
377
|
+
else await launchCodex(persona, extra);
|
|
378
|
+
} else if (normPersona(command)) {
|
|
379
|
+
// uvs pro (shorthand for uvs claude pro)
|
|
380
|
+
const persona = normPersona(command);
|
|
381
|
+
const extra = args.slice(1);
|
|
382
|
+
await launchClaude(persona, extra);
|
|
383
|
+
} else {
|
|
384
|
+
console.error(`Unknown command: ${command}`);
|
|
385
|
+
usage();
|
|
250
386
|
process.exit(1);
|
|
251
387
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
else launchCodex(persona, extra);
|
|
255
|
-
} else if (normPersona(command)) {
|
|
256
|
-
// uv pro (shorthand for uv claude pro)
|
|
257
|
-
const persona = normPersona(command);
|
|
258
|
-
const extra = args.slice(1);
|
|
259
|
-
launchClaude(persona, extra);
|
|
260
|
-
} else {
|
|
261
|
-
console.error(`Unknown command: ${command}`);
|
|
262
|
-
usage();
|
|
388
|
+
})().catch((err) => {
|
|
389
|
+
console.error(err);
|
|
263
390
|
process.exit(1);
|
|
264
|
-
}
|
|
391
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UV Suite helper: locate per-session checkpoint paths and print metadata.
|
|
3
|
+
# Used by the /checkpoint and /restore slash commands.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# checkpoint-helper.sh dir # ensure + print the dir for current session
|
|
7
|
+
# checkpoint-helper.sh meta # print session metadata as shell-eval'able lines
|
|
8
|
+
# checkpoint-helper.sh frontmatter # YAML frontmatter to embed at the top of a checkpoint
|
|
9
|
+
# checkpoint-helper.sh latest # cat the latest checkpoint for current session (with fallback)
|
|
10
|
+
# checkpoint-helper.sh list # list all sessions that have checkpoints, newest first
|
|
11
|
+
|
|
12
|
+
resolve_paths() {
|
|
13
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
14
|
+
STATE_DIR="$PROJECT_DIR/.uv-suite-state"
|
|
15
|
+
SID="${UVS_SESSION_ID:-}"
|
|
16
|
+
if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
|
|
17
|
+
SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
|
|
18
|
+
fi
|
|
19
|
+
CHECKPOINTS_ROOT="$PROJECT_DIR/uv-out/checkpoints"
|
|
20
|
+
SESSION_CP_DIR=""
|
|
21
|
+
[ -n "$SID" ] && SESSION_CP_DIR="$CHECKPOINTS_ROOT/$SID"
|
|
22
|
+
META_FILE=""
|
|
23
|
+
[ -n "$SID" ] && META_FILE="$STATE_DIR/sessions/$SID.json"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
print_meta_field() {
|
|
27
|
+
# $1 = field name; reads from $META_FILE; empty if missing
|
|
28
|
+
[ -z "$META_FILE" ] || [ ! -f "$META_FILE" ] && { echo ""; return; }
|
|
29
|
+
if command -v jq >/dev/null 2>&1; then
|
|
30
|
+
jq -r --arg k "$1" '.[$k] // ""' "$META_FILE" 2>/dev/null
|
|
31
|
+
else
|
|
32
|
+
grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$META_FILE" | head -1 | sed "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\(.*\)\"/\1/"
|
|
33
|
+
fi
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resolve_paths
|
|
37
|
+
|
|
38
|
+
case "$1" in
|
|
39
|
+
dir)
|
|
40
|
+
if [ -n "$SESSION_CP_DIR" ]; then
|
|
41
|
+
mkdir -p "$SESSION_CP_DIR"
|
|
42
|
+
echo "$SESSION_CP_DIR"
|
|
43
|
+
else
|
|
44
|
+
mkdir -p "$CHECKPOINTS_ROOT"
|
|
45
|
+
echo "$CHECKPOINTS_ROOT"
|
|
46
|
+
fi
|
|
47
|
+
;;
|
|
48
|
+
meta)
|
|
49
|
+
echo "uvs_session_id=${SID:-}"
|
|
50
|
+
echo "session_name=$(print_meta_field name)"
|
|
51
|
+
echo "session_kind=$(print_meta_field kind)"
|
|
52
|
+
echo "session_purpose=$(print_meta_field purpose)"
|
|
53
|
+
echo "session_priority=$(print_meta_field priority)"
|
|
54
|
+
echo "persona=$(print_meta_field persona)"
|
|
55
|
+
;;
|
|
56
|
+
frontmatter)
|
|
57
|
+
NAME=$(print_meta_field name)
|
|
58
|
+
KIND=$(print_meta_field kind)
|
|
59
|
+
PURPOSE=$(print_meta_field purpose)
|
|
60
|
+
PRIORITY=$(print_meta_field priority)
|
|
61
|
+
PERSONA=$(print_meta_field persona)
|
|
62
|
+
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
63
|
+
cat <<EOF
|
|
64
|
+
---
|
|
65
|
+
uvs_session_id: ${SID:-}
|
|
66
|
+
session_name: ${NAME}
|
|
67
|
+
session_kind: ${KIND}
|
|
68
|
+
session_purpose: ${PURPOSE}
|
|
69
|
+
session_priority: ${PRIORITY}
|
|
70
|
+
persona: ${PERSONA}
|
|
71
|
+
checkpoint_at: ${NOW}
|
|
72
|
+
---
|
|
73
|
+
EOF
|
|
74
|
+
;;
|
|
75
|
+
latest)
|
|
76
|
+
if [ -n "$SESSION_CP_DIR" ] && [ -f "$SESSION_CP_DIR/latest.md" ]; then
|
|
77
|
+
cat "$SESSION_CP_DIR/latest.md"
|
|
78
|
+
elif [ -f "$CHECKPOINTS_ROOT/latest.md" ]; then
|
|
79
|
+
echo "(no per-session checkpoint for ${SID:-this session}; showing legacy global latest.md)"
|
|
80
|
+
echo
|
|
81
|
+
cat "$CHECKPOINTS_ROOT/latest.md"
|
|
82
|
+
else
|
|
83
|
+
echo "No checkpoint found at $CHECKPOINTS_ROOT. Run /checkpoint to create one."
|
|
84
|
+
fi
|
|
85
|
+
;;
|
|
86
|
+
list)
|
|
87
|
+
[ ! -d "$CHECKPOINTS_ROOT" ] && { echo "No checkpoints directory at $CHECKPOINTS_ROOT"; exit 0; }
|
|
88
|
+
found=0
|
|
89
|
+
for d in "$CHECKPOINTS_ROOT"/*/; do
|
|
90
|
+
[ -d "$d" ] || continue
|
|
91
|
+
cp_sid=$(basename "$d")
|
|
92
|
+
cp_meta="$STATE_DIR/sessions/$cp_sid.json"
|
|
93
|
+
cp_name=""
|
|
94
|
+
cp_priority=""
|
|
95
|
+
if [ -f "$cp_meta" ]; then
|
|
96
|
+
if command -v jq >/dev/null 2>&1; then
|
|
97
|
+
cp_name=$(jq -r '.name // ""' "$cp_meta" 2>/dev/null)
|
|
98
|
+
cp_priority=$(jq -r '.priority // ""' "$cp_meta" 2>/dev/null)
|
|
99
|
+
else
|
|
100
|
+
cp_name=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$cp_meta" | head -1 | sed 's/.*"name"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
latest=$(ls -t "$d"*.md 2>/dev/null | head -1)
|
|
104
|
+
[ -z "$latest" ] && continue
|
|
105
|
+
ts=$(stat -f '%Sm' -t '%Y-%m-%d %H:%M' "$latest" 2>/dev/null || stat -c '%y' "$latest" 2>/dev/null | cut -c1-16)
|
|
106
|
+
label="${cp_name:-(unlabeled)}"
|
|
107
|
+
[ -n "$cp_priority" ] && label="$label [p:$cp_priority]"
|
|
108
|
+
mark=" "
|
|
109
|
+
[ "$cp_sid" = "$SID" ] && mark="*"
|
|
110
|
+
echo "$mark ${cp_sid:0:8} $ts $label"
|
|
111
|
+
found=1
|
|
112
|
+
done
|
|
113
|
+
[ "$found" -eq 0 ] && echo "No per-session checkpoints yet (current session: ${SID:-none})"
|
|
114
|
+
# Note legacy global checkpoint if present
|
|
115
|
+
if [ -f "$CHECKPOINTS_ROOT/latest.md" ]; then
|
|
116
|
+
ts=$(stat -f '%Sm' -t '%Y-%m-%d %H:%M' "$CHECKPOINTS_ROOT/latest.md" 2>/dev/null || stat -c '%y' "$CHECKPOINTS_ROOT/latest.md" 2>/dev/null | cut -c1-16)
|
|
117
|
+
echo " legacy $ts (pre-metadata global latest.md)"
|
|
118
|
+
fi
|
|
119
|
+
;;
|
|
120
|
+
*)
|
|
121
|
+
echo "Usage: checkpoint-helper.sh [dir|meta|frontmatter|latest|list]"
|
|
122
|
+
exit 1
|
|
123
|
+
;;
|
|
124
|
+
esac
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uv-suite",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.4",
|
|
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",
|
|
@@ -3,7 +3,8 @@ name: checkpoint
|
|
|
3
3
|
description: >
|
|
4
4
|
Save a checkpoint of the current session — what was done, key decisions, current state,
|
|
5
5
|
and what's next. Use before ending a session, before /compact, or at any natural breakpoint.
|
|
6
|
-
|
|
6
|
+
Checkpoints are stored per-session under uv-out/checkpoints/<session-id>/, so concurrent
|
|
7
|
+
terminals don't clobber each other. /restore picks up the latest for the current session.
|
|
7
8
|
argument-hint: "[optional-label]"
|
|
8
9
|
user-invocable: true
|
|
9
10
|
allowed-tools:
|
|
@@ -12,39 +13,51 @@ allowed-tools:
|
|
|
12
13
|
- Bash(git status *)
|
|
13
14
|
- Bash(git diff *)
|
|
14
15
|
- Bash(git log *)
|
|
16
|
+
- Bash(git branch *)
|
|
15
17
|
- Bash(git rev-parse *)
|
|
16
18
|
- Bash(date *)
|
|
17
19
|
- Bash(ls *)
|
|
18
20
|
- Bash(mkdir *)
|
|
21
|
+
- Bash(cat *)
|
|
19
22
|
- Bash(echo *)
|
|
23
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh *)
|
|
20
24
|
---
|
|
21
25
|
|
|
22
|
-
## Resolve checkpoint directory
|
|
26
|
+
## Resolve session and checkpoint directory
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
working dir as a last resort) so `/checkpoint` and `/restore` always agree, no matter
|
|
26
|
-
which subdirectory the session was launched from.
|
|
27
|
-
|
|
28
|
-
!`DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/uv-out/checkpoints"; mkdir -p "$DIR"; echo "$DIR"`
|
|
28
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh dir`
|
|
29
29
|
|
|
30
30
|
Use the absolute path printed above as `<checkpoint-dir>` for every file path below.
|
|
31
|
+
The directory is per-session — two `uv` launches in the same repo write to
|
|
32
|
+
different folders, so checkpoints don't collide.
|
|
33
|
+
|
|
34
|
+
## Session metadata
|
|
35
|
+
|
|
36
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh meta`
|
|
37
|
+
|
|
38
|
+
## Frontmatter to embed at the top of the checkpoint
|
|
39
|
+
|
|
40
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh frontmatter`
|
|
31
41
|
|
|
32
42
|
## Write a checkpoint
|
|
33
43
|
|
|
34
|
-
Write a file named `<checkpoint-dir>/YYYY-MM-DD-HHMM.md` using the current
|
|
44
|
+
Write a file named `<checkpoint-dir>/YYYY-MM-DD-HHMM.md` using the current
|
|
45
|
+
timestamp. **Begin the file with the YAML frontmatter block printed above
|
|
46
|
+
exactly as shown** — `/restore` parses these fields when picking which
|
|
47
|
+
checkpoint to load.
|
|
35
48
|
|
|
36
|
-
Also write/overwrite `<checkpoint-dir>/latest.md` with the same content,
|
|
37
|
-
session's `/restore` always finds the freshest state
|
|
49
|
+
Also write/overwrite `<checkpoint-dir>/latest.md` with the same content,
|
|
50
|
+
so the next session's `/restore` always finds the freshest state for this
|
|
51
|
+
session.
|
|
38
52
|
|
|
39
53
|
## Label
|
|
40
54
|
|
|
41
55
|
$ARGUMENTS
|
|
42
56
|
|
|
43
|
-
If a label was provided, include it in the filename:
|
|
44
|
-
|
|
45
|
-
## What to capture
|
|
57
|
+
If a label was provided, include it in the filename:
|
|
58
|
+
`<checkpoint-dir>/YYYY-MM-DD-HHMM-[label].md`
|
|
46
59
|
|
|
47
|
-
|
|
60
|
+
## Body structure (after the frontmatter)
|
|
48
61
|
|
|
49
62
|
```markdown
|
|
50
63
|
# Checkpoint: [date] [time] [label if provided]
|
|
@@ -88,5 +101,5 @@ Review the full conversation so far and write a structured checkpoint with these
|
|
|
88
101
|
|
|
89
102
|
- Be specific. "Worked on auth" is useless. "Added JWT refresh token rotation with 7-day expiry" is useful.
|
|
90
103
|
- Capture WHY decisions were made, not just what. The next session needs the rationale.
|
|
91
|
-
- Keep
|
|
92
|
-
-
|
|
104
|
+
- Keep the body under 80 lines. The frontmatter is required and not counted.
|
|
105
|
+
- Always include the YAML frontmatter — `/restore` reads it to pick the right checkpoint and to display session context.
|
package/skills/restore/SKILL.md
CHANGED
|
@@ -1,25 +1,55 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: restore
|
|
3
3
|
description: >
|
|
4
|
-
Restore the latest checkpoint
|
|
5
|
-
key decisions, current state, and what's next.
|
|
4
|
+
Restore the latest checkpoint for the current session — shows what was done,
|
|
5
|
+
key decisions, current state, and what's next. With no arguments, picks the
|
|
6
|
+
current `UVS_SESSION_ID`'s most recent checkpoint. Pass a session id prefix
|
|
7
|
+
or name to restore from a different session.
|
|
8
|
+
argument-hint: "[<session-id-prefix> | <session-name> | list]"
|
|
6
9
|
user-invocable: true
|
|
7
10
|
allowed-tools:
|
|
8
11
|
- Read(*)
|
|
9
12
|
- Bash(ls *)
|
|
10
13
|
- Bash(cat *)
|
|
11
14
|
- Bash(grep *)
|
|
15
|
+
- Bash(find *)
|
|
12
16
|
- Bash(git rev-parse *)
|
|
17
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh *)
|
|
13
18
|
---
|
|
14
19
|
|
|
15
|
-
##
|
|
20
|
+
## Available sessions with checkpoints
|
|
16
21
|
|
|
17
|
-
!`
|
|
22
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh list`
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
(`*` marks the current session.)
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
## Latest checkpoint for the current session
|
|
27
|
+
|
|
28
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh latest`
|
|
29
|
+
|
|
30
|
+
## Argument
|
|
31
|
+
|
|
32
|
+
$ARGUMENTS
|
|
22
33
|
|
|
23
34
|
## Instructions
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
1. **If `$ARGUMENTS` is empty or "latest"**: read the checkpoint shown above (the
|
|
37
|
+
current session's `latest.md`). Summarize it in 3-4 sentences: what was
|
|
38
|
+
done, current state, what's next. Then ask: "Ready to pick up from here, or
|
|
39
|
+
do you want to take a different direction?"
|
|
40
|
+
|
|
41
|
+
2. **If `$ARGUMENTS` is "list"**: just show the user the available-sessions
|
|
42
|
+
list above and ask which one they want to restore.
|
|
43
|
+
|
|
44
|
+
3. **If `$ARGUMENTS` looks like a session id prefix** (8-char hex / UUID-ish)
|
|
45
|
+
**or a session name**: match it against the list above. Read the
|
|
46
|
+
matching session's `latest.md` from
|
|
47
|
+
`<project>/uv-out/checkpoints/<full-session-id>/latest.md` using the Read
|
|
48
|
+
tool, then summarize as in (1).
|
|
49
|
+
|
|
50
|
+
4. If no match is found, list the available sessions and ask the user to
|
|
51
|
+
pick one.
|
|
52
|
+
|
|
53
|
+
When summarizing, include the session's name and purpose from the
|
|
54
|
+
frontmatter at the top of the checkpoint — that's the context the next
|
|
55
|
+
session needs to know what it's picking up.
|