pi-skill-playbook 1.0.1 → 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
 
@@ -117,15 +133,26 @@ PLAYBOOK_OUTCOME: ready-for-prd
117
133
  | `suggest` | Marker only suggests `/playbook:done` or `/playbook:choose` |
118
134
  | `off` | No prompt injection or completion detection |
119
135
 
136
+ ## Sample playbooks
137
+
138
+ | Playbook | File | When to use |
139
+ |---|---|---|
140
+ | Feature Development | `feature-development.yml` | Generic product work with standard `to-prd` / `to-issues` skills. Good default for non-OSS projects. |
141
+ | Pi OSS New Extension Delivery | `pi-oss-new.yml` | Full Pi OSS lane from idea through PR verify and release post. Uses `-for-oss` skills and vault maintenance skills. |
142
+ | Pi OSS Bootstrap Only | `pi-oss-bootstrap-only.yml` | Repo + vault bootstrap, then PRD and issue slicing only. Stops before implementation. |
143
+ | OSS Maintenance Onboarding | `oss-maintenance-onboard.yml` | Add a new OSS target to the Multica maintenance operating model. |
144
+
145
+ Copy one or more files from `samples/` into `.pi/playbooks/` in the target project. See [`docs/examples.md`](docs/examples.md) for copy-and-start steps.
146
+
120
147
  ## Package contents
121
148
 
122
149
  ```
123
150
  pi-skill-playbook/
124
151
  ├── extensions/ Pi extension entry point
125
152
  ├── src/ Domain logic: validation, state, rendering, auto-advance
126
- ├── samples/ Example playbooks (feature-development.yml)
153
+ ├── samples/ Example playbooks (generic + Pi OSS lane)
127
154
  ├── tests/ Node.js test suite
128
- ├── docs/adr/ Architecture decision records
155
+ ├── docs/ Examples and architecture decision records
129
156
  ├── LICENSE MIT
130
157
  └── README.md
131
158
  ```
@@ -155,7 +182,7 @@ steps:
155
182
  to: complete # "complete" ends the run
156
183
  ```
157
184
 
158
- See [`samples/feature-development.yml`](samples/feature-development.yml) for a full example.
185
+ See [`samples/feature-development.yml`](samples/feature-development.yml) for a generic example and [`samples/pi-oss-new.yml`](samples/pi-oss-new.yml) for the Pi OSS delivery lane.
159
186
 
160
187
  ## Development
161
188
 
@@ -189,6 +216,7 @@ Manual dispatch is also available from the Actions tab.
189
216
  - [npm package](https://www.npmjs.com/package/pi-skill-playbook)
190
217
  - [GitHub repository](https://github.com/eiei114/pi-skill-playbook)
191
218
  - [Pi coding agent](https://github.com/earendil-works/pi-coding-agent)
219
+ - [Roadmap](ROADMAP.md)
192
220
  - [Architecture decisions](docs/adr/)
193
221
 
194
222
  ## License
package/docs/examples.md CHANGED
@@ -1,11 +1,45 @@
1
1
  # Examples
2
2
 
3
- ## Selection-first TUI flow
3
+ ## Copy a sample into your project
4
4
 
5
- Copy or create YAML playbooks in the target project under `.pi/playbooks/`, then use argument-free commands from the Pi TUI:
5
+ Package samples ship under `samples/`. Copy the playbook that matches your lane into the target project's `.pi/playbooks/` folder:
6
+
7
+ ```bash
8
+ mkdir -p .pi/playbooks
9
+
10
+ # Generic feature development
11
+ cp node_modules/pi-skill-playbook/samples/feature-development.yml .pi/playbooks/
12
+
13
+ # Pi OSS delivery lane (idea -> PRD -> issues -> TDD -> review -> PR verify -> release post)
14
+ cp node_modules/pi-skill-playbook/samples/pi-oss-new.yml .pi/playbooks/
15
+
16
+ # Pi OSS bootstrap only (repo + vault setup -> PRD -> issues)
17
+ cp node_modules/pi-skill-playbook/samples/pi-oss-bootstrap-only.yml .pi/playbooks/
18
+
19
+ # Multica OSS maintenance onboarding
20
+ cp node_modules/pi-skill-playbook/samples/oss-maintenance-onboard.yml .pi/playbooks/
21
+ ```
22
+
23
+ Add run state to the target repo's `.gitignore`:
24
+
25
+ ```gitignore
26
+ .pi/playbook-runs/
27
+ ```
28
+
29
+ ## Start a run
30
+
31
+ From the Pi TUI, list playbooks and start one with the selection UI:
6
32
 
7
33
  ```text
34
+ /playbook:list
8
35
  /playbook:start
36
+ ```
37
+
38
+ When multiple playbooks exist, Pi shows a selector with validation status for each file. The run id is generated automatically.
39
+
40
+ ## Drive the workflow
41
+
42
+ ```text
9
43
  /skill:grill-with-docs <feature idea>
10
44
  /playbook:done
11
45
  /playbook:choose
@@ -16,3 +50,5 @@ Copy or create YAML playbooks in the target project under `.pi/playbooks/`, then
16
50
  - `/playbook:resume` opens an active-run selector.
17
51
  - `/playbook:choose` opens a selector for the current step's valid outcomes.
18
52
  - `/playbook:cancel` selects an active run when needed and asks for confirmation before marking it cancelled.
53
+
54
+ For Pi OSS samples, run the skill named in the widget's command hint at each step. Single-outcome steps can auto-advance when the assistant emits a visible `PLAYBOOK_OUTCOME:` marker.
@@ -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.0.1",
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,22 @@
1
+ version: 1
2
+ id: oss-maintenance-onboard
3
+ name: OSS Maintenance Onboarding
4
+ entry: onboard
5
+ autoAdvance: auto
6
+
7
+ skills:
8
+ oss-maintenance-onboarding:
9
+ role: entry
10
+
11
+ steps:
12
+ onboard:
13
+ primarySkill: oss-maintenance-onboarding
14
+ commandHint: "/skill:oss-maintenance-onboarding add this OSS target to Multica maintenance"
15
+ doneWhen:
16
+ - Target is registered in the vault maintenance control surface.
17
+ - Live Multica project is created or linked when requested.
18
+ - Controllers and Autopilot tracking are configured conservatively.
19
+ - Pilot issue verification notes are captured for the first maintenance slice.
20
+ transitions:
21
+ - outcome: complete
22
+ to: complete
@@ -0,0 +1,43 @@
1
+ version: 1
2
+ id: pi-oss-bootstrap-only
3
+ name: Pi OSS Bootstrap Only
4
+ entry: bootstrap
5
+ autoAdvance: auto
6
+
7
+ skills:
8
+ pi-oss-bootstrap:
9
+ role: entry
10
+ to-prd-for-oss:
11
+ role: internal
12
+ to-issues-for-oss:
13
+ role: internal
14
+
15
+ steps:
16
+ bootstrap:
17
+ primarySkill: pi-oss-bootstrap
18
+ commandHint: "/skill:pi-oss-bootstrap bootstrap a new Pi extension OSS project"
19
+ doneWhen:
20
+ - OSS repo exists under Projects/OSS/.
21
+ - Vault project folder exists under 4_Project/OSS/<project_key>/.
22
+ transitions:
23
+ - outcome: ready-for-prd
24
+ to: prd
25
+
26
+ prd:
27
+ primarySkill: to-prd-for-oss
28
+ commandHint: "/skill:to-prd-for-oss create PRD in 4_Project/<project>/Docs/"
29
+ doneWhen:
30
+ - PRD exists under the target OSS project Docs folder.
31
+ transitions:
32
+ - outcome: ready-for-issues
33
+ to: issues
34
+
35
+ issues:
36
+ primarySkill: to-issues-for-oss
37
+ commandHint: "/skill:to-issues-for-oss break PRD into tracer-bullet issues"
38
+ doneWhen:
39
+ - Issue files exist under 4_Project/<project>/Issues/.
40
+ - Issues are independently grabbable.
41
+ transitions:
42
+ - outcome: complete
43
+ to: complete
@@ -0,0 +1,91 @@
1
+ version: 1
2
+ id: pi-oss-new
3
+ name: Pi OSS New Extension Delivery
4
+ entry: grill
5
+ autoAdvance: auto
6
+
7
+ skills:
8
+ grill-with-docs:
9
+ role: entry
10
+ to-prd-for-oss:
11
+ role: internal
12
+ to-issues-for-oss:
13
+ role: internal
14
+ tdd:
15
+ role: internal
16
+ review:
17
+ role: internal
18
+ pi-extension-pr-verify:
19
+ role: internal
20
+ x-release-post:
21
+ role: internal
22
+
23
+ steps:
24
+ grill:
25
+ primarySkill: grill-with-docs
26
+ commandHint: "/skill:grill-with-docs <Pi OSS extension idea>"
27
+ doneWhen:
28
+ - Problem boundary is clear for the new Pi OSS project.
29
+ - Key terminology and repo layout are resolved.
30
+ transitions:
31
+ - outcome: ready-for-prd
32
+ to: prd
33
+
34
+ prd:
35
+ primarySkill: to-prd-for-oss
36
+ commandHint: "/skill:to-prd-for-oss create PRD in 4_Project/<project>/Docs/"
37
+ doneWhen:
38
+ - PRD exists under the target OSS project Docs folder.
39
+ transitions:
40
+ - outcome: ready-for-issues
41
+ to: issues
42
+
43
+ issues:
44
+ primarySkill: to-issues-for-oss
45
+ commandHint: "/skill:to-issues-for-oss break PRD into tracer-bullet issues"
46
+ doneWhen:
47
+ - Issue files exist under 4_Project/<project>/Issues/.
48
+ - Issues are independently grabbable.
49
+ transitions:
50
+ - outcome: ready-for-implementation
51
+ to: implement
52
+
53
+ implement:
54
+ primarySkill: tdd
55
+ commandHint: "/skill:tdd implement the next OSS issue"
56
+ doneWhen:
57
+ - Tests pass.
58
+ - Implementation is complete for the current slice.
59
+ transitions:
60
+ - outcome: ready-for-review
61
+ to: review
62
+
63
+ review:
64
+ primarySkill: review
65
+ commandHint: "/skill:review review this branch against the plan"
66
+ doneWhen:
67
+ - Standards and spec review results are known.
68
+ transitions:
69
+ - outcome: pass
70
+ to: pr-verify
71
+ - outcome: fail
72
+ to: implement
73
+
74
+ pr-verify:
75
+ primarySkill: pi-extension-pr-verify
76
+ commandHint: "/skill:pi-extension-pr-verify verify the PR locally"
77
+ doneWhen:
78
+ - PR worktree is set up and automated checks pass.
79
+ - Manual Pi TUI verification checklist is complete.
80
+ transitions:
81
+ - outcome: ready-for-release
82
+ to: release-post
83
+
84
+ release-post:
85
+ primarySkill: x-release-post
86
+ commandHint: "/skill:x-release-post draft X release announcement"
87
+ doneWhen:
88
+ - English and Japanese release post drafts are saved.
89
+ transitions:
90
+ - outcome: complete
91
+ to: complete
@@ -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
+ }