@tcanaud/playbook 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/bin/cli.js +60 -0
- package/package.json +31 -0
- package/src/detect.js +118 -0
- package/src/installer.js +142 -0
- package/src/session.js +493 -0
- package/src/updater.js +85 -0
- package/src/validator.js +289 -0
- package/src/worktree.js +120 -0
- package/src/yaml-parser.js +682 -0
- package/templates/commands/playbook.resume.md +143 -0
- package/templates/commands/playbook.run.md +214 -0
- package/templates/core/_index.yaml +15 -0
- package/templates/core/playbook.tpl.yaml +88 -0
- package/templates/playbooks/auto-feature.yaml +100 -0
- package/templates/playbooks/auto-validate.yaml +31 -0
package/src/session.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session management for playbook supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Handles session ID generation, session manifest and journal I/O.
|
|
5
|
+
* All YAML is written manually — zero runtime dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Session ID
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates a session ID in the format `{YYYYMMDD}-{3char}`.
|
|
23
|
+
* The 3-char suffix is random lowercase alphanumeric (a-z0-9).
|
|
24
|
+
*
|
|
25
|
+
* @returns {string} e.g. "20260219-a7k"
|
|
26
|
+
*/
|
|
27
|
+
export function generateSessionId() {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const yyyy = now.getFullYear();
|
|
30
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
31
|
+
const dd = String(now.getDate()).padStart(2, "0");
|
|
32
|
+
const date = `${yyyy}${mm}${dd}`;
|
|
33
|
+
|
|
34
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
35
|
+
let suffix = "";
|
|
36
|
+
for (let i = 0; i < 3; i++) {
|
|
37
|
+
suffix += chars[Math.floor(Math.random() * chars.length)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${date}-${suffix}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Session creation
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new session directory with session.yaml and journal.yaml.
|
|
49
|
+
* Retries up to 3 times on session ID collision (directory already exists).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} sessionsDir - Absolute path to the sessions directory.
|
|
52
|
+
* @param {{ playbookName: string, feature: string, args: Record<string, string>, worktree?: string }} options
|
|
53
|
+
* @returns {string} The generated session ID.
|
|
54
|
+
* @throws {Error} If a unique session ID cannot be generated after 3 attempts.
|
|
55
|
+
*/
|
|
56
|
+
export function createSession(sessionsDir, { playbookName, feature, args, worktree }) {
|
|
57
|
+
const MAX_RETRIES = 3;
|
|
58
|
+
|
|
59
|
+
let sessionId;
|
|
60
|
+
let sessionDir;
|
|
61
|
+
|
|
62
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
63
|
+
sessionId = generateSessionId();
|
|
64
|
+
sessionDir = join(sessionsDir, sessionId);
|
|
65
|
+
if (!existsSync(sessionDir)) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (attempt === MAX_RETRIES - 1) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to generate a unique session ID after ${MAX_RETRIES} attempts.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
sessionId = null;
|
|
74
|
+
sessionDir = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
const startedAt = new Date().toISOString();
|
|
80
|
+
|
|
81
|
+
const sessionYaml = serializeSession({
|
|
82
|
+
session_id: sessionId,
|
|
83
|
+
playbook: playbookName,
|
|
84
|
+
feature,
|
|
85
|
+
args: args ?? {},
|
|
86
|
+
status: "pending",
|
|
87
|
+
started_at: startedAt,
|
|
88
|
+
completed_at: "",
|
|
89
|
+
current_step: "",
|
|
90
|
+
worktree: worktree ?? "",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
writeFileSync(join(sessionDir, "session.yaml"), sessionYaml, "utf8");
|
|
94
|
+
writeFileSync(join(sessionDir, "journal.yaml"), "entries: []\n", "utf8");
|
|
95
|
+
|
|
96
|
+
return sessionId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Session read / update
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reads and parses session.yaml from a session directory.
|
|
105
|
+
* Uses simple line-by-line regex parsing — no YAML library.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} sessionDir - Absolute path to the session directory.
|
|
108
|
+
* @returns {{ session_id: string, playbook: string, feature: string, args: Record<string, string>, status: string, started_at: string, completed_at: string, current_step: string, worktree: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function readSession(sessionDir) {
|
|
111
|
+
const content = readFileSync(join(sessionDir, "session.yaml"), "utf8");
|
|
112
|
+
return parseSession(content);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Updates fields in session.yaml by merging provided fields into the existing manifest.
|
|
117
|
+
* Only fields explicitly passed are updated.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} sessionDir - Absolute path to the session directory.
|
|
120
|
+
* @param {Partial<{ session_id: string, playbook: string, feature: string, args: Record<string, string>, status: string, started_at: string, completed_at: string, current_step: string, worktree: string }>} fields
|
|
121
|
+
*/
|
|
122
|
+
export function updateSession(sessionDir, fields) {
|
|
123
|
+
const current = readSession(sessionDir);
|
|
124
|
+
const merged = { ...current, ...fields };
|
|
125
|
+
const yaml = serializeSession(merged);
|
|
126
|
+
writeFileSync(join(sessionDir, "session.yaml"), yaml, "utf8");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Journal
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Appends a new entry to journal.yaml in the session directory.
|
|
135
|
+
* Reads the existing journal, appends, then rewrites the file.
|
|
136
|
+
*
|
|
137
|
+
* Entry fields:
|
|
138
|
+
* - step_id (required): string
|
|
139
|
+
* - status (required): "done" | "failed" | "skipped" | "in_progress"
|
|
140
|
+
* - decision (required): "auto" | "gate" | "escalated" | "skipped"
|
|
141
|
+
* - started_at (required): ISO 8601
|
|
142
|
+
* - completed_at (optional): ISO 8601
|
|
143
|
+
* - duration_seconds (optional): number, computed from timestamps if absent
|
|
144
|
+
* - trigger (optional): string — escalation trigger that fired
|
|
145
|
+
* - human_response (optional): string — developer's response at gate
|
|
146
|
+
* - error (optional): string — error message
|
|
147
|
+
*
|
|
148
|
+
* Optional fields are omitted from YAML when undefined.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} sessionDir
|
|
151
|
+
* @param {{ step_id: string, status: string, decision: string, started_at: string, completed_at?: string, duration_seconds?: number, trigger?: string, human_response?: string, error?: string }} entry
|
|
152
|
+
*/
|
|
153
|
+
export function appendJournalEntry(sessionDir, entry) {
|
|
154
|
+
const journal = readJournal(sessionDir);
|
|
155
|
+
|
|
156
|
+
// Compute duration_seconds from timestamps if not provided but both timestamps exist.
|
|
157
|
+
let duration = entry.duration_seconds;
|
|
158
|
+
if (duration === undefined && entry.started_at && entry.completed_at) {
|
|
159
|
+
const start = Date.parse(entry.started_at);
|
|
160
|
+
const end = Date.parse(entry.completed_at);
|
|
161
|
+
if (!isNaN(start) && !isNaN(end)) {
|
|
162
|
+
duration = Math.round((end - start) / 1000);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const normalized = {
|
|
167
|
+
step_id: entry.step_id,
|
|
168
|
+
status: entry.status,
|
|
169
|
+
decision: entry.decision,
|
|
170
|
+
started_at: entry.started_at,
|
|
171
|
+
...(entry.completed_at !== undefined ? { completed_at: entry.completed_at } : {}),
|
|
172
|
+
...(duration !== undefined ? { duration_seconds: duration } : {}),
|
|
173
|
+
...(entry.trigger !== undefined ? { trigger: entry.trigger } : {}),
|
|
174
|
+
...(entry.human_response !== undefined ? { human_response: entry.human_response } : {}),
|
|
175
|
+
...(entry.error !== undefined ? { error: entry.error } : {}),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
journal.entries.push(normalized);
|
|
179
|
+
|
|
180
|
+
const yaml = serializeJournal(journal);
|
|
181
|
+
writeFileSync(join(sessionDir, "journal.yaml"), yaml, "utf8");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Reads and parses journal.yaml from a session directory.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} sessionDir
|
|
188
|
+
* @returns {{ entries: Array<object> }}
|
|
189
|
+
*/
|
|
190
|
+
export function readJournal(sessionDir) {
|
|
191
|
+
const content = readFileSync(join(sessionDir, "journal.yaml"), "utf8");
|
|
192
|
+
return parseJournal(content);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Session discovery
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Scans {playbooksDir}/sessions/ for sessions with status "in_progress".
|
|
201
|
+
* Returns results sorted by most recent first (by session ID timestamp prefix).
|
|
202
|
+
*
|
|
203
|
+
* @param {string} playbooksDir - Absolute path to the .playbooks/ directory.
|
|
204
|
+
* @returns {Array<{ sessionId: string, sessionDir: string, manifest: object }>}
|
|
205
|
+
*/
|
|
206
|
+
export function findInProgressSessions(playbooksDir) {
|
|
207
|
+
const sessionsDir = join(playbooksDir, "sessions");
|
|
208
|
+
|
|
209
|
+
if (!existsSync(sessionsDir)) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = readdirSync(sessionsDir, { withFileTypes: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const results = [];
|
|
221
|
+
|
|
222
|
+
for (const dirent of entries) {
|
|
223
|
+
if (!dirent.isDirectory()) continue;
|
|
224
|
+
|
|
225
|
+
const sessionDir = join(sessionsDir, dirent.name);
|
|
226
|
+
const manifestPath = join(sessionDir, "session.yaml");
|
|
227
|
+
|
|
228
|
+
if (!existsSync(manifestPath)) continue;
|
|
229
|
+
|
|
230
|
+
let manifest;
|
|
231
|
+
try {
|
|
232
|
+
manifest = readSession(sessionDir);
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (manifest.status === "in_progress") {
|
|
238
|
+
results.push({
|
|
239
|
+
sessionId: dirent.name,
|
|
240
|
+
sessionDir,
|
|
241
|
+
manifest,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Sort by session ID descending (most recent first).
|
|
247
|
+
// Session IDs are "{YYYYMMDD}-{3char}" — lexicographic sort works correctly.
|
|
248
|
+
results.sort((a, b) => {
|
|
249
|
+
if (a.sessionId > b.sessionId) return -1;
|
|
250
|
+
if (a.sessionId < b.sessionId) return 1;
|
|
251
|
+
return 0;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Internal: YAML serialization
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Serializes a session manifest object to YAML string.
|
|
263
|
+
*
|
|
264
|
+
* @param {{ session_id: string, playbook: string, feature: string, args: Record<string, string>, status: string, started_at: string, completed_at: string, current_step: string, worktree: string }} session
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
function serializeSession(session) {
|
|
268
|
+
const lines = [];
|
|
269
|
+
|
|
270
|
+
lines.push(`session_id: "${escape(session.session_id)}"`);
|
|
271
|
+
lines.push(`playbook: "${escape(session.playbook)}"`);
|
|
272
|
+
lines.push(`feature: "${escape(session.feature)}"`);
|
|
273
|
+
|
|
274
|
+
const argsObj = session.args ?? {};
|
|
275
|
+
const argKeys = Object.keys(argsObj);
|
|
276
|
+
if (argKeys.length === 0) {
|
|
277
|
+
lines.push("args: {}");
|
|
278
|
+
} else {
|
|
279
|
+
lines.push("args:");
|
|
280
|
+
for (const key of argKeys) {
|
|
281
|
+
lines.push(` ${key}: "${escape(String(argsObj[key]))}"`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
lines.push(`status: "${escape(session.status)}"`);
|
|
286
|
+
lines.push(`started_at: "${escape(session.started_at)}"`);
|
|
287
|
+
lines.push(`completed_at: "${escape(session.completed_at ?? "")}"`);
|
|
288
|
+
lines.push(`current_step: "${escape(session.current_step ?? "")}"`);
|
|
289
|
+
lines.push(`worktree: "${escape(session.worktree ?? "")}"`);
|
|
290
|
+
|
|
291
|
+
return lines.join("\n") + "\n";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Serializes a journal object to YAML string.
|
|
296
|
+
*
|
|
297
|
+
* @param {{ entries: Array<object> }} journal
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function serializeJournal(journal) {
|
|
301
|
+
if (journal.entries.length === 0) {
|
|
302
|
+
return "entries: []\n";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const lines = ["entries:"];
|
|
306
|
+
|
|
307
|
+
for (const entry of journal.entries) {
|
|
308
|
+
lines.push(` - step_id: "${escape(entry.step_id)}"`);
|
|
309
|
+
lines.push(` status: "${escape(entry.status)}"`);
|
|
310
|
+
lines.push(` decision: "${escape(entry.decision)}"`);
|
|
311
|
+
lines.push(` started_at: "${escape(entry.started_at)}"`);
|
|
312
|
+
|
|
313
|
+
if (entry.completed_at !== undefined) {
|
|
314
|
+
lines.push(` completed_at: "${escape(entry.completed_at)}"`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (entry.duration_seconds !== undefined) {
|
|
318
|
+
lines.push(` duration_seconds: ${Number(entry.duration_seconds)}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (entry.trigger !== undefined) {
|
|
322
|
+
lines.push(` trigger: "${escape(entry.trigger)}"`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (entry.human_response !== undefined) {
|
|
326
|
+
lines.push(` human_response: "${escape(entry.human_response)}"`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (entry.error !== undefined) {
|
|
330
|
+
lines.push(` error: "${escape(entry.error)}"`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return lines.join("\n") + "\n";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Internal: YAML parsing
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parses a session.yaml content string into a session manifest object.
|
|
343
|
+
* Uses regex line-by-line parsing — no YAML library.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} content
|
|
346
|
+
* @returns {object}
|
|
347
|
+
*/
|
|
348
|
+
function parseSession(content) {
|
|
349
|
+
const session = {
|
|
350
|
+
session_id: "",
|
|
351
|
+
playbook: "",
|
|
352
|
+
feature: "",
|
|
353
|
+
args: {},
|
|
354
|
+
status: "",
|
|
355
|
+
started_at: "",
|
|
356
|
+
completed_at: "",
|
|
357
|
+
current_step: "",
|
|
358
|
+
worktree: "",
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const lines = content.split("\n");
|
|
362
|
+
let inArgs = false;
|
|
363
|
+
|
|
364
|
+
for (let i = 0; i < lines.length; i++) {
|
|
365
|
+
const line = lines[i];
|
|
366
|
+
|
|
367
|
+
// Detect transition out of args block when we hit a top-level key.
|
|
368
|
+
if (inArgs) {
|
|
369
|
+
// Indented lines inside args block: " key: "value""
|
|
370
|
+
const argMatch = line.match(/^ ([^:]+):\s*"(.*)"$/);
|
|
371
|
+
if (argMatch) {
|
|
372
|
+
session.args[argMatch[1].trim()] = unescape(argMatch[2]);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Not an indented args line — fall through to normal processing.
|
|
376
|
+
inArgs = false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Top-level scalar fields with quoted values.
|
|
380
|
+
const quotedMatch = line.match(/^([a-z_]+):\s*"(.*)"$/);
|
|
381
|
+
if (quotedMatch) {
|
|
382
|
+
const key = quotedMatch[1];
|
|
383
|
+
const value = unescape(quotedMatch[2]);
|
|
384
|
+
if (key in session && key !== "args") {
|
|
385
|
+
session[key] = value;
|
|
386
|
+
}
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// args: {} (empty map)
|
|
391
|
+
if (line.match(/^args:\s*\{\}$/)) {
|
|
392
|
+
session.args = {};
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// args: (start of block)
|
|
397
|
+
if (line.match(/^args:\s*$/)) {
|
|
398
|
+
inArgs = true;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return session;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Parses a journal.yaml content string into a journal object.
|
|
408
|
+
* Uses line-by-line parsing — no YAML library.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} content
|
|
411
|
+
* @returns {{ entries: Array<object> }}
|
|
412
|
+
*/
|
|
413
|
+
function parseJournal(content) {
|
|
414
|
+
const trimmed = content.trim();
|
|
415
|
+
|
|
416
|
+
// Empty journal.
|
|
417
|
+
if (trimmed === "entries: []" || trimmed === "entries:[]") {
|
|
418
|
+
return { entries: [] };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const lines = content.split("\n");
|
|
422
|
+
const entries = [];
|
|
423
|
+
let current = null;
|
|
424
|
+
|
|
425
|
+
for (const line of lines) {
|
|
426
|
+
// New entry starts with " - step_id:"
|
|
427
|
+
const entryStart = line.match(/^ - step_id:\s*"(.*)"$/);
|
|
428
|
+
if (entryStart) {
|
|
429
|
+
if (current !== null) {
|
|
430
|
+
entries.push(current);
|
|
431
|
+
}
|
|
432
|
+
current = { step_id: unescape(entryStart[1]) };
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (current === null) continue;
|
|
437
|
+
|
|
438
|
+
// Quoted string fields inside an entry (indented with 4 spaces).
|
|
439
|
+
const quotedField = line.match(/^ ([a-z_]+):\s*"(.*)"$/);
|
|
440
|
+
if (quotedField) {
|
|
441
|
+
current[quotedField[1]] = unescape(quotedField[2]);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Numeric field (duration_seconds).
|
|
446
|
+
const numericField = line.match(/^ ([a-z_]+):\s*(\d+)$/);
|
|
447
|
+
if (numericField) {
|
|
448
|
+
current[numericField[1]] = Number(numericField[2]);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (current !== null) {
|
|
454
|
+
entries.push(current);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { entries };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Internal: string escaping for YAML double-quoted scalars
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Escapes a string value for safe inclusion inside YAML double quotes.
|
|
466
|
+
* Escapes: backslash, double-quote, and common control characters.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} value
|
|
469
|
+
* @returns {string}
|
|
470
|
+
*/
|
|
471
|
+
function escape(value) {
|
|
472
|
+
return String(value)
|
|
473
|
+
.replace(/\\/g, "\\\\")
|
|
474
|
+
.replace(/"/g, '\\"')
|
|
475
|
+
.replace(/\n/g, "\\n")
|
|
476
|
+
.replace(/\r/g, "\\r")
|
|
477
|
+
.replace(/\t/g, "\\t");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Unescapes a YAML double-quoted scalar value.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} value
|
|
484
|
+
* @returns {string}
|
|
485
|
+
*/
|
|
486
|
+
function unescape(value) {
|
|
487
|
+
return value
|
|
488
|
+
.replace(/\\t/g, "\t")
|
|
489
|
+
.replace(/\\r/g, "\r")
|
|
490
|
+
.replace(/\\n/g, "\n")
|
|
491
|
+
.replace(/\\"/g, '"')
|
|
492
|
+
.replace(/\\\\/g, "\\");
|
|
493
|
+
}
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TEMPLATES = join(__dirname, "..", "templates");
|
|
13
|
+
|
|
14
|
+
function copyTemplate(src, dest) {
|
|
15
|
+
const destDir = dirname(dest);
|
|
16
|
+
if (!existsSync(destDir)) {
|
|
17
|
+
mkdirSync(destDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
copyFileSync(src, dest);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function update(flags = []) {
|
|
23
|
+
const projectRoot = process.cwd();
|
|
24
|
+
|
|
25
|
+
console.log("\n playbook update\n");
|
|
26
|
+
|
|
27
|
+
if (!existsSync(join(projectRoot, ".playbooks"))) {
|
|
28
|
+
console.error(
|
|
29
|
+
" Error: .playbooks/ not found. Run `npx @tcanaud/playbook init` first."
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Overwrite built-in playbooks
|
|
35
|
+
console.log(" Updating built-in playbooks...");
|
|
36
|
+
|
|
37
|
+
const playbookMappings = [
|
|
38
|
+
["playbooks/auto-feature.yaml", ".playbooks/playbooks/auto-feature.yaml"],
|
|
39
|
+
["playbooks/auto-validate.yaml", ".playbooks/playbooks/auto-validate.yaml"],
|
|
40
|
+
["core/playbook.tpl.yaml", ".playbooks/playbooks/playbook.tpl.yaml"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const [src, dest] of playbookMappings) {
|
|
44
|
+
const srcPath = join(TEMPLATES, src);
|
|
45
|
+
if (existsSync(srcPath)) {
|
|
46
|
+
copyTemplate(srcPath, join(projectRoot, dest));
|
|
47
|
+
console.log(` updated ${dest}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Regenerate _index.yaml
|
|
52
|
+
console.log("\n Regenerating _index.yaml...");
|
|
53
|
+
|
|
54
|
+
const indexTemplatePath = join(TEMPLATES, "core/_index.yaml");
|
|
55
|
+
if (existsSync(indexTemplatePath)) {
|
|
56
|
+
const timestamp = new Date().toISOString();
|
|
57
|
+
const content = readFileSync(indexTemplatePath, "utf8").replace(
|
|
58
|
+
"{{TIMESTAMP}}",
|
|
59
|
+
timestamp
|
|
60
|
+
);
|
|
61
|
+
const indexDest = join(projectRoot, ".playbooks/_index.yaml");
|
|
62
|
+
writeFileSync(indexDest, content, "utf8");
|
|
63
|
+
console.log(` updated .playbooks/_index.yaml`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Overwrite slash command files
|
|
67
|
+
console.log("\n Updating Claude Code commands...");
|
|
68
|
+
|
|
69
|
+
const commandMappings = [
|
|
70
|
+
["commands/playbook.run.md", ".claude/commands/playbook.run.md"],
|
|
71
|
+
["commands/playbook.resume.md", ".claude/commands/playbook.resume.md"],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const [src, dest] of commandMappings) {
|
|
75
|
+
const srcPath = join(TEMPLATES, src);
|
|
76
|
+
if (existsSync(srcPath)) {
|
|
77
|
+
copyTemplate(srcPath, join(projectRoot, dest));
|
|
78
|
+
console.log(` updated ${dest}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log();
|
|
83
|
+
console.log(" Done! Playbooks and commands updated.");
|
|
84
|
+
console.log(" Your .playbooks/sessions/ data is untouched.\n");
|
|
85
|
+
}
|