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 +17 -1
- package/extensions/index.ts +56 -6
- package/package.json +1 -1
- 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
|
|
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,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
|
+
}
|