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 +32 -4
- package/docs/examples.md +38 -2
- package/extensions/index.ts +56 -6
- package/package.json +1 -1
- package/samples/oss-maintenance-onboard.yml +22 -0
- package/samples/pi-oss-bootstrap-only.yml +43 -0
- package/samples/pi-oss-new.yml +91 -0
- package/src/draft-save.ts +81 -0
- package/src/gitignore.ts +37 -4
- package/src/record-handlers.ts +225 -0
- package/src/record-state.ts +72 -0
- package/src/record-types.ts +34 -0
- package/src/record.ts +219 -0
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.
|
|
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 (
|
|
153
|
+
├── samples/ Example playbooks (generic + Pi OSS lane)
|
|
127
154
|
├── tests/ Node.js test suite
|
|
128
|
-
├── docs/
|
|
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
|
|
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
|
-
##
|
|
3
|
+
## Copy a sample into your project
|
|
4
4
|
|
|
5
|
-
Copy
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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${
|
|
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
|
+
}
|