@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/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
+ }