pi-skill-playbook 1.1.0 → 1.2.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/README.md CHANGED
@@ -23,6 +23,7 @@ Define ordered skill workflows as YAML playbooks in your project, then drive the
23
23
  - **Active run widget** — Displays current step, skill command, completion criteria, and outcome labels below the editor.
24
24
  - **Strict YAML validation** — Playbooks are validated on load for structure, transitions, and skill references.
25
25
  - **Selection UI** — Playbook, run, and outcome selection use the Pi TUI selector instead of memorized ids.
26
+ - **Record command** — Capture explicit skill usage with `/playbook:record:*` marks and convert the flow into a validated playbook draft.
26
27
  - **Local run state** — Run state is stored in `.pi/playbook-runs/` inside the target project, never in git.
27
28
 
28
29
  ## Install
@@ -67,6 +68,7 @@ pi -e .
67
68
 
68
69
  ```gitignore
69
70
  .pi/playbook-runs/
71
+ .pi/playbook-records/
70
72
  ```
71
73
 
72
74
  3. **Start a run** from the Pi TUI:
@@ -100,8 +102,22 @@ The widget displays the current step, exact skill command, completion criteria,
100
102
  | `/playbook:done` | Complete the current step (auto-advances if single outcome) |
101
103
  | `/playbook:choose` | Select an outcome for multi-branch steps |
102
104
  | `/playbook:cancel` | Select and confirm an active run cancellation |
105
+ | `/playbook:record:start <id> [--name <name>]` | Start recording an explicit skill flow |
106
+ | `/playbook:record:mark [<skill>]` | Mark explicit skill usage (selection UI when omitted) |
107
+ | `/playbook:record:branch <outcome>` | Record a branch outcome label before the next skill mark |
108
+ | `/playbook:record:stop` | Preview, validate, confirm, and save a recorded playbook draft |
109
+ | `/playbook:record:status` | Show the active recording session |
103
110
 
104
- All commands are argument-free. Use the Pi TUI selection UI to pick playbooks, runs, and outcomes.
111
+ All core run commands are argument-free. Record subcommands use explicit marks and optional args as shown above.
112
+
113
+ ### Record vs import-web
114
+
115
+ | Command | Source | When to use |
116
+ |---|---|---|
117
+ | `/playbook:record:*` | Your own explicit skill usage during day-to-day work | Grow playbooks from internal flows you already run |
118
+ | `/playbook:import-web` | Web search + URLs (deferred) | Import external workflow articles with Required Source Trace |
119
+
120
+ Record never scrapes session logs or infers skills automatically. Import-web (when implemented) never replaces recording — it complements it for external sources.
105
121
 
106
122
  ### Auto advance
107
123
 
@@ -5,9 +5,36 @@ import { findPlaybook, loadPlaybooks } from "../src/playbooks.js";
5
5
  import { getGitignoreAdvisory } from "../src/gitignore.js";
6
6
  import { renderStepCard, renderValidationErrors } from "../src/render.js";
7
7
  import { normalizeSkillCommandName, validatePlaybook, validateUniquePlaybookIds } from "../src/validation.js";
8
+ import { handleRecordCommand, RECORD_COMMANDS, recordUsage } from "../src/record-handlers.js";
8
9
  import type { LoadedPlaybook, PlaybookRunState } from "../src/types.js";
9
10
 
10
11
  const WIDGET_ID = "pi-skill-playbook";
12
+ let gitignoreAdvisoryShownThisSession = false;
13
+
14
+ export function resetGitignoreAdvisorySessionForTest(): void {
15
+ gitignoreAdvisoryShownThisSession = false;
16
+ }
17
+
18
+ async function notifyWithGitignoreAdvisory(
19
+ cwd: string,
20
+ ui: UiLike | undefined,
21
+ lines: string[],
22
+ defaultLevel: "info" | "warning" = "info",
23
+ ): Promise<void> {
24
+ if (gitignoreAdvisoryShownThisSession) {
25
+ notify(ui, lines.join("\n"), defaultLevel);
26
+ return;
27
+ }
28
+
29
+ const advisory = await getGitignoreAdvisory(cwd);
30
+ if (!advisory) {
31
+ notify(ui, lines.join("\n"), defaultLevel);
32
+ return;
33
+ }
34
+
35
+ gitignoreAdvisoryShownThisSession = true;
36
+ notify(ui, [...lines, "", advisory].join("\n"), "warning");
37
+ }
11
38
 
12
39
  const COMMANDS = [
13
40
  ["list", "list available playbooks"],
@@ -90,6 +117,30 @@ export default function piSkillPlaybook(pi: ExtensionAPI) {
90
117
  },
91
118
  });
92
119
  }
120
+
121
+ for (const [command, description] of RECORD_COMMANDS) {
122
+ pi.registerCommand(`playbook:${command}`, {
123
+ description: `Playbook: ${description}.`,
124
+ handler: async (args, ctx) => {
125
+ try {
126
+ await handleRecordCommand(pi, command, args, ctx);
127
+ } catch (error) {
128
+ notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error");
129
+ }
130
+ },
131
+ });
132
+ }
133
+
134
+ pi.registerCommand("playbook:record", {
135
+ description: "Playbook: record an explicit skill flow into a draft.",
136
+ handler: async (args, ctx) => {
137
+ try {
138
+ await handleRecordCommand(pi, "record", args, ctx);
139
+ } catch (error) {
140
+ notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error");
141
+ }
142
+ },
143
+ });
93
144
  }
94
145
 
95
146
  export async function handlePlaybookCommand(
@@ -124,7 +175,6 @@ export async function handlePlaybookCommand(
124
175
  await cancelRun(ctx.cwd, ui);
125
176
  return;
126
177
  case "import-web":
127
- case "record":
128
178
  notify(ui, `/playbook:${command} is deferred after the Core 6 MVP scaffold.`, "warning");
129
179
  return;
130
180
  default:
@@ -172,8 +222,7 @@ async function createAndActivateRun(cwd: string, playbook: LoadedPlaybook, runNa
172
222
  await saveRun(cwd, run);
173
223
  await setActiveRun(cwd, run.runId);
174
224
  renderWidget(ui, playbook, run);
175
- const advisory = await getGitignoreAdvisory(cwd);
176
- notify(ui, [`Started ${run.runId}.`, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info");
225
+ await notifyWithGitignoreAdvisory(cwd, ui, [`Started ${run.runId}.`]);
177
226
  }
178
227
 
179
228
  async function resumeRun(cwd: string, ui: UiLike | undefined): Promise<void> {
@@ -184,7 +233,7 @@ async function resumeRun(cwd: string, ui: UiLike | undefined): Promise<void> {
184
233
  if (!playbook) throw new Error(`Run '${run.runId}' references missing playbook '${run.playbookId}'.`);
185
234
  await setActiveRun(cwd, run.runId);
186
235
  renderWidget(ui, playbook, run);
187
- notify(ui, `Resumed ${run.runId}.`, "info");
236
+ await notifyWithGitignoreAdvisory(cwd, ui, [`Resumed ${run.runId}.`]);
188
237
  }
189
238
 
190
239
  async function cancelRun(cwd: string, ui: UiLike | undefined): Promise<void> {
@@ -226,8 +275,7 @@ async function showStatus(cwd: string, ui: UiLike | undefined): Promise<void> {
226
275
  if (!playbook) throw new Error(`Run '${runId}' references missing playbook '${run.playbookId}'.`);
227
276
  const lines = renderStepCard(playbook, run);
228
277
  renderWidget(ui, playbook, run);
229
- const advisory = await getGitignoreAdvisory(cwd);
230
- notify(ui, [...lines, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info");
278
+ await notifyWithGitignoreAdvisory(cwd, ui, lines);
231
279
  }
232
280
 
233
281
  async function completeCurrentStep(cwd: string, ui: UiLike | undefined): Promise<void> {
@@ -508,6 +556,8 @@ function usage(): string {
508
556
  "/playbook:done",
509
557
  "/playbook:choose",
510
558
  "/playbook:cancel",
559
+ "",
560
+ recordUsage(),
511
561
  ].join("\n");
512
562
  }
513
563
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-playbook",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for passive, human-mediated Agent Skill playbooks.",
6
6
  "keywords": ["pi-package", "pi-extension", "agent-skills", "playbook"],
@@ -0,0 +1,81 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { stringify } from "yaml";
4
+ import { PLAYBOOK_DIR } from "./playbooks.js";
5
+ import { renderValidationErrors } from "./render.js";
6
+ import type { LoadedPlaybook, PlaybookDefinition } from "./types.js";
7
+ import { parsePlaybookYaml, validatePlaybook } from "./validation.js";
8
+
9
+ export interface DraftSaveUi {
10
+ notify(message: string, level: "info" | "warning" | "error"): void;
11
+ confirm?(title: string, message: string): Promise<boolean>;
12
+ }
13
+
14
+ export function definitionToYaml(definition: PlaybookDefinition): string {
15
+ return `${stringify(definition)}\n`;
16
+ }
17
+
18
+ export function previewDraft(definition: PlaybookDefinition, targetPath: string): string {
19
+ const yaml = definitionToYaml(definition);
20
+ return [`Target: ${targetPath}`, "", yaml.trimEnd()].join("\n");
21
+ }
22
+
23
+ export function validateDraftDefinition(
24
+ definition: PlaybookDefinition,
25
+ targetPath: string,
26
+ availableSkills: ReadonlySet<string>,
27
+ ): { loaded: LoadedPlaybook; result: ReturnType<typeof validatePlaybook> } {
28
+ const loaded = parsePlaybookYaml(definitionToYaml(definition), targetPath);
29
+ const result = validatePlaybook(loaded, availableSkills, { requireSkills: true });
30
+ return { loaded, result };
31
+ }
32
+
33
+ export async function savePlaybookDraft(
34
+ cwd: string,
35
+ definition: PlaybookDefinition,
36
+ availableSkills: ReadonlySet<string>,
37
+ ui: DraftSaveUi | undefined,
38
+ options: { sourceLabel: string },
39
+ ): Promise<boolean> {
40
+ const targetPath = join(cwd, PLAYBOOK_DIR, `${definition.id}.yml`);
41
+ const preview = previewDraft(definition, targetPath);
42
+ const { result } = validateDraftDefinition(definition, targetPath, availableSkills);
43
+
44
+ notify(ui, preview, "info");
45
+
46
+ if (!result.valid) {
47
+ notify(ui, `${options.sourceLabel} draft validation failed:\n${renderValidationErrors(result.errors)}`, "error");
48
+ return false;
49
+ }
50
+
51
+ if (!ui?.confirm) {
52
+ notify(ui, "Confirmation UI is required before saving a recorded draft.", "error");
53
+ return false;
54
+ }
55
+
56
+ const confirmed = await ui.confirm(
57
+ `Save ${options.sourceLabel} playbook draft?`,
58
+ `Write ${definition.id}.yml to ${PLAYBOOK_DIR}/ after validation.`,
59
+ );
60
+ if (!confirmed) {
61
+ notify(ui, `${options.sourceLabel} draft save skipped.`, "info");
62
+ return false;
63
+ }
64
+
65
+ await mkdir(join(cwd, PLAYBOOK_DIR), { recursive: true });
66
+ try {
67
+ await writeFile(targetPath, definitionToYaml(definition), { encoding: "utf8", flag: "wx" });
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
70
+ notify(ui, `${options.sourceLabel} draft already exists at ${targetPath}.`, "error");
71
+ return false;
72
+ }
73
+ throw error;
74
+ }
75
+ notify(ui, `Saved playbook draft to ${targetPath}.`, "info");
76
+ return true;
77
+ }
78
+
79
+ function notify(ui: DraftSaveUi | undefined, message: string, level: "info" | "warning" | "error"): void {
80
+ ui?.notify(message, level);
81
+ }
package/src/gitignore.ts CHANGED
@@ -2,13 +2,46 @@ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { RUNS_DIR } from "./state.js";
4
4
 
5
+ export const GITIGNORE_SNIPPET = `${RUNS_DIR}/`;
6
+
7
+ const IGNORE_PATTERNS = new Set([
8
+ RUNS_DIR,
9
+ `${RUNS_DIR}/`,
10
+ `/${RUNS_DIR}`,
11
+ `/${RUNS_DIR}/`,
12
+ `${RUNS_DIR}/**`,
13
+ ".pi",
14
+ ".pi/",
15
+ "/.pi",
16
+ "/.pi/",
17
+ ".pi/**",
18
+ "/.pi/**",
19
+ ]);
20
+
21
+ export function isPlaybookRunsGitignored(gitignoreContent: string): boolean {
22
+ let ignored = false;
23
+ for (const raw of gitignoreContent.split(/\r?\n/)) {
24
+ const line = raw.trim();
25
+ if (!line || line.startsWith("#")) continue;
26
+
27
+ const negated = line.startsWith("!");
28
+ const candidate = negated ? line.slice(1).trim() : line;
29
+ if (!IGNORE_PATTERNS.has(candidate)) continue;
30
+
31
+ ignored = !negated;
32
+ }
33
+ return ignored;
34
+ }
35
+
5
36
  export async function getGitignoreAdvisory(cwd: string): Promise<string | undefined> {
6
37
  try {
7
38
  const gitignore = await readFile(join(cwd, ".gitignore"), "utf8");
8
- const lines = gitignore.split(/\r?\n/).map((line) => line.trim());
9
- if (lines.includes(RUNS_DIR) || lines.includes(`${RUNS_DIR}/`)) return undefined;
10
- } catch {
39
+ if (isPlaybookRunsGitignored(gitignore)) return undefined;
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
42
+ throw error;
43
+ }
11
44
  // Missing .gitignore still needs advisory.
12
45
  }
13
- return `Run state is personal. Add this to .gitignore:\n${RUNS_DIR}/`;
46
+ return `Run state is personal. Add this to .gitignore:\n${GITIGNORE_SNIPPET}`;
14
47
  }
@@ -0,0 +1,225 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { savePlaybookDraft } from "./draft-save.js";
3
+ import {
4
+ createRecordSession,
5
+ markSkill,
6
+ recordBranch,
7
+ recordSessionToDefinition,
8
+ renderRecordStatus,
9
+ validatePlaybookId,
10
+ } from "./record.js";
11
+ import {
12
+ clearActiveRecordSession,
13
+ loadActiveRecordSession,
14
+ saveRecordSession,
15
+ setActiveRecordSession,
16
+ } from "./record-state.js";
17
+ import { normalizeSkillCommandName } from "./validation.js";
18
+
19
+ type RecordUi = {
20
+ notify(message: string, level: "info" | "warning" | "error"): void;
21
+ select?(title: string, options: string[]): Promise<string | undefined>;
22
+ confirm?(title: string, message: string): Promise<boolean>;
23
+ };
24
+
25
+ type RecordContext = {
26
+ cwd: string;
27
+ hasUI: boolean;
28
+ ui?: RecordUi;
29
+ };
30
+
31
+ export const RECORD_COMMANDS = [
32
+ ["record:start", "start recording an explicit skill flow"],
33
+ ["record:mark", "mark explicit skill usage in the active recording"],
34
+ ["record:branch", "record a branch outcome label"],
35
+ ["record:stop", "stop recording and save a playbook draft"],
36
+ ["record:status", "show active recording status"],
37
+ ] as const;
38
+
39
+ export function recordUsage(): string {
40
+ return [
41
+ "Usage:",
42
+ "/playbook:record:start <playbook-id> [--name <display name>]",
43
+ "/playbook:record:mark [<skill-name>]",
44
+ "/playbook:record:branch <outcome-label> [--to <step-id>]",
45
+ "/playbook:record:stop",
46
+ "/playbook:record:status",
47
+ ].join("\n");
48
+ }
49
+
50
+ export async function handleRecordCommand(
51
+ pi: ExtensionAPI,
52
+ command: string,
53
+ args: string,
54
+ ctx: RecordContext,
55
+ ): Promise<void> {
56
+ const ui = ctx.hasUI ? ctx.ui : undefined;
57
+ const tokens = tokenize(args);
58
+
59
+ switch (command) {
60
+ case "record":
61
+ if (tokens.length === 0) {
62
+ await showRecordStatus(ctx.cwd, ui);
63
+ notify(ui, recordUsage(), "info");
64
+ return;
65
+ }
66
+ notify(ui, recordUsage(), "error");
67
+ return;
68
+ case "record:start":
69
+ await startRecording(tokens, ctx.cwd, ui);
70
+ return;
71
+ case "record:mark":
72
+ await markRecordingSkill(pi, tokens, ctx.cwd, ui);
73
+ return;
74
+ case "record:branch":
75
+ await branchRecording(tokens, ctx.cwd, ui);
76
+ return;
77
+ case "record:stop":
78
+ await stopRecording(pi, ctx.cwd, ui);
79
+ return;
80
+ case "record:status":
81
+ await showRecordStatus(ctx.cwd, ui);
82
+ return;
83
+ default:
84
+ notify(ui, recordUsage(), "error");
85
+ }
86
+ }
87
+
88
+ async function startRecording(tokens: string[], cwd: string, ui: RecordUi | undefined): Promise<void> {
89
+ const playbookId = tokens[0];
90
+ if (!playbookId) {
91
+ notify(ui, "Playbook id is required. Example: /playbook:record:start my-recorded-flow", "error");
92
+ return;
93
+ }
94
+
95
+ validatePlaybookId(playbookId);
96
+ const nameFlag = tokens.indexOf("--name");
97
+ const playbookName = nameFlag >= 0 ? tokens.slice(nameFlag + 1).join(" ").trim() : titleCase(playbookId);
98
+ if (nameFlag >= 0 && !playbookName) {
99
+ notify(ui, "Display name is required after --name.", "error");
100
+ return;
101
+ }
102
+
103
+ const existing = await loadActiveRecordSession(cwd);
104
+ if (existing) {
105
+ notify(ui, `Recording already active (${existing.playbookId}). Run /playbook:record:stop first.`, "warning");
106
+ return;
107
+ }
108
+
109
+ const session = createRecordSession(playbookId, playbookName);
110
+ await saveRecordSession(cwd, session);
111
+ await setActiveRecordSession(cwd, session.sessionId);
112
+ notify(ui, [`Started recording ${playbookId}.`, ...renderRecordStatus(session)].join("\n"), "info");
113
+ }
114
+
115
+ async function markRecordingSkill(
116
+ pi: ExtensionAPI,
117
+ tokens: string[],
118
+ cwd: string,
119
+ ui: RecordUi | undefined,
120
+ ): Promise<void> {
121
+ const session = await requireActiveSession(cwd, ui);
122
+ if (!session) return;
123
+
124
+ const availableSkills = getAvailableSkills(pi);
125
+ let skillName = tokens[0];
126
+ if (!skillName) {
127
+ const selected = await pickSkill(pi, ui);
128
+ if (!selected) return;
129
+ skillName = selected;
130
+ } else {
131
+ skillName = normalizeSkillCommandName(skillName);
132
+ if (!availableSkills.has(skillName)) {
133
+ notify(ui, `Unknown Agent Skill '${skillName}'.`, "error");
134
+ return;
135
+ }
136
+ }
137
+
138
+ const updated = markSkill(session, skillName);
139
+ await saveRecordSession(cwd, updated);
140
+ notify(ui, [`Marked skill '${skillName}' on step '${updated.currentStepId}'.`, ...renderRecordStatus(updated)].join("\n"), "info");
141
+ }
142
+
143
+ async function branchRecording(tokens: string[], cwd: string, ui: RecordUi | undefined): Promise<void> {
144
+ const session = await requireActiveSession(cwd, ui);
145
+ if (!session) return;
146
+
147
+ const outcome = tokens[0];
148
+ if (!outcome) {
149
+ notify(ui, "Outcome label is required. Example: /playbook:record:branch ready-for-prd", "error");
150
+ return;
151
+ }
152
+
153
+ const toFlag = tokens.indexOf("--to");
154
+ const toStepId = toFlag >= 0 ? tokens[toFlag + 1] : undefined;
155
+ const updated = recordBranch(session, outcome, toStepId);
156
+ await saveRecordSession(cwd, updated);
157
+ notify(ui, [`Recorded branch outcome '${outcome}'.`, ...renderRecordStatus(updated)].join("\n"), "info");
158
+ }
159
+
160
+ async function stopRecording(pi: ExtensionAPI, cwd: string, ui: RecordUi | undefined): Promise<void> {
161
+ const session = await requireActiveSession(cwd, ui);
162
+ if (!session) return;
163
+
164
+ const definition = recordSessionToDefinition(session);
165
+ const saved = await savePlaybookDraft(cwd, definition, getAvailableSkills(pi), ui, { sourceLabel: "Recorded" });
166
+ if (saved) {
167
+ await clearActiveRecordSession(cwd);
168
+ notify(ui, `Recording ${session.playbookId} converted to playbook draft.`, "info");
169
+ }
170
+ }
171
+
172
+ async function showRecordStatus(cwd: string, ui: RecordUi | undefined): Promise<void> {
173
+ const session = await loadActiveRecordSession(cwd);
174
+ if (!session) {
175
+ notify(ui, "No active recording. Start one with /playbook:record:start <playbook-id>.", "info");
176
+ return;
177
+ }
178
+ notify(ui, renderRecordStatus(session).join("\n"), "info");
179
+ }
180
+
181
+ async function requireActiveSession(cwd: string, ui: RecordUi | undefined) {
182
+ const session = await loadActiveRecordSession(cwd);
183
+ if (!session) {
184
+ notify(ui, "No active recording. Start one with /playbook:record:start <playbook-id>.", "error");
185
+ return undefined;
186
+ }
187
+ return session;
188
+ }
189
+
190
+ async function pickSkill(pi: ExtensionAPI, ui: RecordUi | undefined): Promise<string | undefined> {
191
+ const skills = [...getAvailableSkills(pi)].sort();
192
+ if (skills.length === 0) {
193
+ notify(ui, "No Agent Skills are available to mark.", "error");
194
+ return undefined;
195
+ }
196
+ if (!ui?.select) {
197
+ notify(ui, "Skill name is required when selection UI is unavailable. Example: /playbook:record:mark grill-with-docs", "error");
198
+ return undefined;
199
+ }
200
+ const selected = await ui.select("Mark which skill?", skills);
201
+ return selected ? normalizeSkillCommandName(selected) : undefined;
202
+ }
203
+
204
+ function getAvailableSkills(pi: ExtensionAPI): ReadonlySet<string> {
205
+ const skills = pi.getCommands()
206
+ .filter((command) => command.source === "skill")
207
+ .map((command) => normalizeSkillCommandName(command.name));
208
+ return new Set(skills);
209
+ }
210
+
211
+ function tokenize(args: string): string[] {
212
+ return args.trim().split(/\s+/).filter(Boolean);
213
+ }
214
+
215
+ function titleCase(slug: string): string {
216
+ return slug
217
+ .split("-")
218
+ .filter(Boolean)
219
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
220
+ .join(" ");
221
+ }
222
+
223
+ function notify(ui: RecordUi | undefined, message: string, level: "info" | "warning" | "error"): void {
224
+ ui?.notify(message, level);
225
+ }
@@ -0,0 +1,72 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { RecordSession } from "./record-types.js";
4
+
5
+ export const RECORDS_DIR = ".pi/playbook-records";
6
+ const ACTIVE_FILE = "active.json";
7
+ const SESSION_ID_PATTERN = /^record-[a-z0-9][a-z0-9-]*-\d+$/;
8
+
9
+ function assertValidSessionId(sessionId: string): string {
10
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
11
+ throw new Error(`Invalid record session id '${sessionId}'.`);
12
+ }
13
+ return sessionId;
14
+ }
15
+
16
+ export function recordsDir(cwd: string): string {
17
+ return join(cwd, RECORDS_DIR);
18
+ }
19
+
20
+ export function sessionFile(cwd: string, sessionId: string): string {
21
+ return join(recordsDir(cwd), `${assertValidSessionId(sessionId)}.json`);
22
+ }
23
+
24
+ export function activeRecordFile(cwd: string): string {
25
+ return join(recordsDir(cwd), ACTIVE_FILE);
26
+ }
27
+
28
+ export async function saveRecordSession(cwd: string, session: RecordSession): Promise<void> {
29
+ await mkdir(recordsDir(cwd), { recursive: true });
30
+ await writeFile(sessionFile(cwd, session.sessionId), `${JSON.stringify(session, null, 2)}\n`, "utf8");
31
+ }
32
+
33
+ export async function loadRecordSession(cwd: string, sessionId: string): Promise<RecordSession | undefined> {
34
+ try {
35
+ return JSON.parse(await readFile(sessionFile(cwd, sessionId), "utf8")) as RecordSession;
36
+ } catch (error) {
37
+ if (isNotFound(error)) return undefined;
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ export async function setActiveRecordSession(cwd: string, sessionId: string): Promise<void> {
43
+ assertValidSessionId(sessionId);
44
+ await mkdir(recordsDir(cwd), { recursive: true });
45
+ await writeFile(activeRecordFile(cwd), `${JSON.stringify({ sessionId }, null, 2)}\n`, "utf8");
46
+ }
47
+
48
+ export async function loadActiveRecordSessionId(cwd: string): Promise<string | undefined> {
49
+ try {
50
+ const state = JSON.parse(await readFile(activeRecordFile(cwd), "utf8")) as { sessionId?: string };
51
+ return typeof state.sessionId === "string" && SESSION_ID_PATTERN.test(state.sessionId)
52
+ ? state.sessionId
53
+ : undefined;
54
+ } catch (error) {
55
+ if (isNotFound(error)) return undefined;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export async function clearActiveRecordSession(cwd: string): Promise<void> {
61
+ await rm(activeRecordFile(cwd), { force: true });
62
+ }
63
+
64
+ export async function loadActiveRecordSession(cwd: string): Promise<RecordSession | undefined> {
65
+ const sessionId = await loadActiveRecordSessionId(cwd);
66
+ if (!sessionId) return undefined;
67
+ return loadRecordSession(cwd, sessionId);
68
+ }
69
+
70
+ function isNotFound(error: unknown): boolean {
71
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
72
+ }
@@ -0,0 +1,34 @@
1
+ import type { PlaybookTransition } from "./types.js";
2
+
3
+ export type RecordMarkKind = "skill" | "branch";
4
+
5
+ export interface RecordMark {
6
+ at: string;
7
+ kind: RecordMarkKind;
8
+ skillName?: string;
9
+ outcome?: string;
10
+ toStepId?: string;
11
+ }
12
+
13
+ export interface RecordedStepDraft {
14
+ id: string;
15
+ primarySkill: string;
16
+ commandHint: string;
17
+ doneWhen: string[];
18
+ transitions: PlaybookTransition[];
19
+ closed: boolean;
20
+ }
21
+
22
+ export interface RecordSession {
23
+ sessionId: string;
24
+ playbookId: string;
25
+ playbookName: string;
26
+ status: "recording";
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ entryStepId: string | null;
30
+ currentStepId: string | null;
31
+ steps: Record<string, RecordedStepDraft>;
32
+ pendingBranch: { fromStepId: string; outcome: string } | null;
33
+ marks: RecordMark[];
34
+ }
package/src/record.ts ADDED
@@ -0,0 +1,219 @@
1
+ import type { PlaybookDefinition, PlaybookSkillDefinition } from "./types.js";
2
+ import type { RecordMark, RecordSession, RecordedStepDraft } from "./record-types.js";
3
+
4
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
5
+
6
+ export function createRecordSession(playbookId: string, playbookName: string, now = new Date().toISOString()): RecordSession {
7
+ validatePlaybookId(playbookId);
8
+ return {
9
+ sessionId: `record-${playbookId}-${stamp(now)}`,
10
+ playbookId,
11
+ playbookName,
12
+ status: "recording",
13
+ createdAt: now,
14
+ updatedAt: now,
15
+ entryStepId: null,
16
+ currentStepId: null,
17
+ steps: {},
18
+ pendingBranch: null,
19
+ marks: [],
20
+ };
21
+ }
22
+
23
+ export function markSkill(session: RecordSession, skillName: string, now = new Date().toISOString()): RecordSession {
24
+ const normalizedSkillName = skillName.trim();
25
+ validateSkillName(normalizedSkillName);
26
+ const next = cloneSession(session);
27
+
28
+ if (next.pendingBranch) {
29
+ const step = createStep(normalizedSkillName, next.steps);
30
+ next.steps[step.id] = step;
31
+ linkPendingBranch(next, step.id);
32
+ next.entryStepId ??= step.id;
33
+ next.currentStepId = step.id;
34
+ next.marks.push({ at: now, kind: "skill", skillName: normalizedSkillName });
35
+ next.updatedAt = now;
36
+ return next;
37
+ }
38
+
39
+ if (!next.currentStepId) {
40
+ const step = createStep(normalizedSkillName, next.steps);
41
+ next.steps[step.id] = step;
42
+ next.entryStepId = step.id;
43
+ next.currentStepId = step.id;
44
+ next.marks.push({ at: now, kind: "skill", skillName: normalizedSkillName });
45
+ next.updatedAt = now;
46
+ return next;
47
+ }
48
+
49
+ const current = next.steps[next.currentStepId];
50
+ if (!current) throw new Error(`Current step '${next.currentStepId}' is missing.`);
51
+ if (!current.closed) {
52
+ throw new Error(`Step '${current.id}' is still open. Run /playbook:record:branch <outcome> before marking the next skill.`);
53
+ }
54
+
55
+ throw new Error("No pending branch outcome. Run /playbook:record:branch <outcome> before marking the next skill.");
56
+ }
57
+
58
+ export function recordBranch(
59
+ session: RecordSession,
60
+ outcome: string,
61
+ toStepId?: string,
62
+ now = new Date().toISOString(),
63
+ ): RecordSession {
64
+ const label = outcome.trim();
65
+ if (!label) throw new Error("Branch outcome label is required.");
66
+
67
+ const next = cloneSession(session);
68
+ if (!next.currentStepId) throw new Error("No step is open. Run /playbook:record:mark first.");
69
+
70
+ const current = next.steps[next.currentStepId];
71
+ if (!current) throw new Error(`Current step '${next.currentStepId}' is missing.`);
72
+ if (current.closed) throw new Error(`Step '${current.id}' is already closed.`);
73
+
74
+ if (current.transitions.some((transition) => transition.outcome === label)) {
75
+ throw new Error(`Outcome '${label}' is already recorded for step '${current.id}'.`);
76
+ }
77
+
78
+ if (toStepId) {
79
+ if (!(toStepId in next.steps)) throw new Error(`Target step '${toStepId}' does not exist.`);
80
+ current.transitions.push({ outcome: label, to: toStepId });
81
+ current.closed = true;
82
+ next.currentStepId = toStepId;
83
+ next.pendingBranch = null;
84
+ next.marks.push({ at: now, kind: "branch", outcome: label, toStepId });
85
+ next.updatedAt = now;
86
+ return next;
87
+ }
88
+
89
+ current.transitions.push({ outcome: label, to: "pending" });
90
+ current.closed = true;
91
+ next.pendingBranch = { fromStepId: current.id, outcome: label };
92
+ next.currentStepId = null;
93
+ next.marks.push({ at: now, kind: "branch", outcome: label });
94
+ next.updatedAt = now;
95
+ return next;
96
+ }
97
+
98
+ export function finalizeRecordSession(session: RecordSession, now = new Date().toISOString()): RecordSession {
99
+ const next = cloneSession(session);
100
+ if (!next.entryStepId) throw new Error("Recording is empty. Mark at least one skill before stopping.");
101
+
102
+ if (next.pendingBranch) {
103
+ throw new Error(`Pending branch outcome '${next.pendingBranch.outcome}' needs a target skill mark.`);
104
+ }
105
+
106
+ if (next.currentStepId) {
107
+ const current = next.steps[next.currentStepId];
108
+ if (current && !current.closed) {
109
+ current.transitions.push({ outcome: "complete", to: "complete" });
110
+ current.closed = true;
111
+ }
112
+ }
113
+
114
+ for (const step of Object.values(next.steps)) {
115
+ for (const transition of step.transitions) {
116
+ if (transition.to === "pending") {
117
+ throw new Error(`Step '${step.id}' still has an unresolved branch target.`);
118
+ }
119
+ }
120
+ }
121
+
122
+ next.updatedAt = now;
123
+ return next;
124
+ }
125
+
126
+ export function recordSessionToDefinition(session: RecordSession): PlaybookDefinition {
127
+ const finalized = finalizeRecordSession(session);
128
+ const skills: Record<string, PlaybookSkillDefinition> = {};
129
+ const steps: PlaybookDefinition["steps"] = {};
130
+
131
+ for (const step of Object.values(finalized.steps)) {
132
+ if (!(step.primarySkill in skills)) {
133
+ skills[step.primarySkill] = { role: step.id === finalized.entryStepId ? "entry" : "internal" };
134
+ }
135
+ steps[step.id] = {
136
+ primarySkill: step.primarySkill,
137
+ commandHint: step.commandHint,
138
+ doneWhen: step.doneWhen,
139
+ transitions: step.transitions.map((transition) => ({
140
+ outcome: transition.outcome,
141
+ to: transition.to,
142
+ })),
143
+ };
144
+ }
145
+
146
+ return {
147
+ version: 1,
148
+ id: finalized.playbookId,
149
+ name: finalized.playbookName,
150
+ entry: finalized.entryStepId!,
151
+ autoAdvance: "auto",
152
+ skills,
153
+ steps,
154
+ };
155
+ }
156
+
157
+ export function renderRecordStatus(session: RecordSession): string[] {
158
+ const lines = [
159
+ `Recording ${session.playbookId} (${session.playbookName})`,
160
+ `Session: ${session.sessionId}`,
161
+ `Steps: ${Object.keys(session.steps).length}`,
162
+ `Marks: ${session.marks.length}`,
163
+ ];
164
+ if (session.currentStepId) {
165
+ const step = session.steps[session.currentStepId];
166
+ lines.push(`Current step: ${session.currentStepId}${step?.closed ? " (closed)" : " (open)"}`);
167
+ } else if (session.pendingBranch) {
168
+ lines.push(`Pending branch: ${session.pendingBranch.outcome} → awaiting next skill mark`);
169
+ }
170
+ return lines;
171
+ }
172
+
173
+ export function validatePlaybookId(playbookId: string): void {
174
+ if (!ID_PATTERN.test(playbookId)) {
175
+ throw new Error(`Playbook id '${playbookId}' must be lower-kebab-case.`);
176
+ }
177
+ }
178
+
179
+ function createStep(skillName: string, existing: Record<string, RecordedStepDraft>): RecordedStepDraft {
180
+ const id = uniqueStepId(skillName, existing);
181
+ return {
182
+ id,
183
+ primarySkill: skillName,
184
+ commandHint: `/skill:${skillName}`,
185
+ doneWhen: [`Recorded completion criteria for ${skillName}.`],
186
+ transitions: [],
187
+ closed: false,
188
+ };
189
+ }
190
+
191
+ function uniqueStepId(skillName: string, existing: Record<string, RecordedStepDraft>): string {
192
+ const base = skillName.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "step";
193
+ if (!(base in existing)) return base;
194
+ let index = 2;
195
+ while (`${base}-${index}` in existing) index += 1;
196
+ return `${base}-${index}`;
197
+ }
198
+
199
+ function linkPendingBranch(session: RecordSession, toStepId: string): void {
200
+ if (!session.pendingBranch) return;
201
+ const from = session.steps[session.pendingBranch.fromStepId];
202
+ if (!from) throw new Error(`Pending branch source '${session.pendingBranch.fromStepId}' is missing.`);
203
+ const transition = from.transitions.find((candidate) => candidate.outcome === session.pendingBranch!.outcome);
204
+ if (!transition) throw new Error(`Pending branch outcome '${session.pendingBranch.outcome}' is missing.`);
205
+ transition.to = toStepId;
206
+ session.pendingBranch = null;
207
+ }
208
+
209
+ function validateSkillName(skillName: string): void {
210
+ if (!skillName.trim()) throw new Error("Skill name is required.");
211
+ }
212
+
213
+ function cloneSession(session: RecordSession): RecordSession {
214
+ return structuredClone(session);
215
+ }
216
+
217
+ function stamp(now: string): string {
218
+ return now.replace(/[-:.TZ]/g, "");
219
+ }