@danypops/papyrus 0.22.0 → 0.24.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 +3 -0
- package/extension/src/artifact-status-presentation.ts +5 -0
- package/extension/src/domain-tools.ts +46 -0
- package/extension/src/index.ts +13 -1
- package/extension/src/playbook-bridge.ts +90 -0
- package/extension/src/playbooks.ts +97 -0
- package/package.json +1 -1
- package/src/cli.ts +110 -0
- package/src/constants.ts +2 -1
- package/src/db.ts +20 -0
- package/src/domain-services.ts +93 -0
- package/src/modules/playbooks.ts +77 -0
- package/src/service.ts +11 -0
package/README.md
CHANGED
|
@@ -122,6 +122,7 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
|
122
122
|
- **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
|
|
123
123
|
- **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
124
124
|
- **`skills`** — create/update/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
|
|
125
|
+
- **`playbooks`** — a completely different beast from Skills, not a subtype: a trigger and an ordered list of steps an agent reads and follows, never mechanically instantiated and never composed the way Skills call other Skills. create/update/list/show/invoke, enable/disable
|
|
125
126
|
|
|
126
127
|
Every tool operation is registered in the daemon’s `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
|
|
127
128
|
|
|
@@ -143,6 +144,8 @@ Tasks, Docs, Rules, and Skills all support first-class `update` (title/body/labe
|
|
|
143
144
|
- `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
|
|
144
145
|
- `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
|
|
145
146
|
- `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
|
|
147
|
+
- `/playbooks` — trigger/tools rows, edit, invocation into the editor, and graph links
|
|
148
|
+
- `/playbook <name>` — tab-completes active playbook titles and places that one's invocation directly in the editor, one step instead of browse-then-select; no argument falls back to the full `/playbooks` browser
|
|
146
149
|
|
|
147
150
|
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
|
|
148
151
|
|
|
@@ -36,6 +36,11 @@ export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
|
36
36
|
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export const PLAYBOOK_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
40
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
41
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
42
|
+
};
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Keyed by extra.discussion.state, not the shared Doc status column -- a settled Discussion's
|
|
41
46
|
* doc.status becomes "archived", but a deferred one stays "active" at the doc level (see
|
|
@@ -521,6 +521,52 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
521
521
|
},
|
|
522
522
|
});
|
|
523
523
|
|
|
524
|
+
pi.registerTool({
|
|
525
|
+
name: "playbooks",
|
|
526
|
+
label: "Playbooks",
|
|
527
|
+
description: "Playbook domain tool -- a completely different beast from the skills tool, not a subtype of it. A Playbook is a trigger and an ordered list of steps an agent reads and follows; it is never mechanically instantiated the way a Skill's artifact-template or workflow blueprint is, and it never composes other Playbooks. ACTIONS: create, list, show, invoke, enable, disable, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. invoke renders the trigger/steps/tools into guidance plus any real linked artifacts. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Playbook to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the playbook's exact title) over `id` -- id is a backend implementation detail, resolved from name automatically.",
|
|
528
|
+
parameters: Type.Object({
|
|
529
|
+
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
530
|
+
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
531
|
+
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
532
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
533
|
+
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
|
|
534
|
+
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
535
|
+
}),
|
|
536
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Playbooks", args, theme); },
|
|
537
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
538
|
+
async execute(_id, rawParams) {
|
|
539
|
+
try {
|
|
540
|
+
const params: Record<string, unknown> = { ...rawParams };
|
|
541
|
+
const action = params.action;
|
|
542
|
+
await resolveNameFields(params, [
|
|
543
|
+
{ nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: { project_root: params.project_root } },
|
|
544
|
+
]);
|
|
545
|
+
if (action === "create") {
|
|
546
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
|
|
547
|
+
return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
|
|
548
|
+
}
|
|
549
|
+
if (action === "list") {
|
|
550
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", params);
|
|
551
|
+
return text(rows.length ? artifactLines(rows).join("\n") : "No playbooks found.", createArtifactListDetails("playbooks.list", rows));
|
|
552
|
+
}
|
|
553
|
+
if (action === "invoke") {
|
|
554
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", params);
|
|
555
|
+
return text(invocation, createPreviewDetails("playbooks.invoke", "Playbook invocation", invocation));
|
|
556
|
+
}
|
|
557
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
558
|
+
if (trashResult) return trashResult;
|
|
559
|
+
const operations = { show: "playbooks.show", enable: "playbooks.enable", disable: "playbooks.disable", assign_project: "playbooks.assign_project", update: "playbooks.update" } as const;
|
|
560
|
+
const operation = operations[action as keyof typeof operations];
|
|
561
|
+
if (!operation) throw new Error(`unknown playbooks action: ${action}`);
|
|
562
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
563
|
+
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
564
|
+
} catch (error) {
|
|
565
|
+
throw new Error(`playbooks failed: ${error instanceof Error ? error.message : error}`);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
524
570
|
pi.registerTool({
|
|
525
571
|
name: "skills",
|
|
526
572
|
label: "Skills",
|
package/extension/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
22
22
|
import { formatMetadata } from "./artifact-format.ts";
|
|
23
23
|
import { callService } from "./service-client.ts";
|
|
24
24
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
25
|
+
import { registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
25
26
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
26
27
|
import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
27
28
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
@@ -159,6 +160,7 @@ class TaskOverlay {
|
|
|
159
160
|
export default async function (pi: ExtensionAPI) {
|
|
160
161
|
setTaskFocusEventBus(pi);
|
|
161
162
|
registerDomainTools(pi);
|
|
163
|
+
registerPlaybookBridge(pi);
|
|
162
164
|
let contextInjectionSequence = 0;
|
|
163
165
|
const contextInjectionProducerId = randomUUID();
|
|
164
166
|
let previousContextInjectionFingerprint: string | undefined;
|
|
@@ -416,12 +418,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
416
418
|
// ── Interactive artifact browsers ──────────────────────────────────
|
|
417
419
|
|
|
418
420
|
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
419
|
-
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, discussModule] = await Promise.all([
|
|
421
|
+
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, playbooksModule, discussModule] = await Promise.all([
|
|
420
422
|
import("./tasks.ts"),
|
|
421
423
|
import("./docs.ts"),
|
|
422
424
|
import("./notes.ts"),
|
|
423
425
|
import("./rules.ts"),
|
|
424
426
|
import("./skills.ts"),
|
|
427
|
+
import("./playbooks.ts"),
|
|
425
428
|
import("./discuss.ts"),
|
|
426
429
|
]);
|
|
427
430
|
let overlay: TaskOverlay | undefined;
|
|
@@ -455,6 +458,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
455
458
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
456
459
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
457
460
|
});
|
|
461
|
+
pi.registerCommand("playbooks", {
|
|
462
|
+
description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
|
|
463
|
+
handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
|
|
464
|
+
});
|
|
465
|
+
pi.registerCommand("playbook", {
|
|
466
|
+
description: "Open one Papyrus playbook directly by name (tab-completes active playbook titles) and place its invocation in the editor; no argument opens the full /playbooks browser instead",
|
|
467
|
+
getArgumentCompletions: (argumentPrefix) => playbooksModule.playbookArgumentCompletions(argumentPrefix),
|
|
468
|
+
handler: async (args, ctx) => { await playbooksModule.openPlaybookByName(args, ctx); },
|
|
469
|
+
});
|
|
458
470
|
pi.registerCommand("discuss", {
|
|
459
471
|
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
460
472
|
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playbook-bridge.ts — materializes active Papyrus Playbooks as real SKILL.md files so they
|
|
3
|
+
* show up in Pi's own native skill catalog and become /skill:name-invocable, through Pi's
|
|
4
|
+
* unmodified, documented resources_discover mechanism (no Pi source touched).
|
|
5
|
+
*
|
|
6
|
+
* Playbooks live in SQLite, not on disk, so they can't satisfy Pi's skill-loading pipeline
|
|
7
|
+
* directly (Skill.filePath is required there). This bridges the gap the other direction:
|
|
8
|
+
* on every resources_discover (session start and /reload), wipe and rebuild a cache directory
|
|
9
|
+
* from the current playbooks.list, so a disabled/removed/renamed Playbook's stale file is never
|
|
10
|
+
* served. Any failure degrades to "no extra skills this cycle" -- a Papyrus daemon hiccup must
|
|
11
|
+
* never break Pi's own resource discovery.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
18
|
+
import { callService } from "./service-client.ts";
|
|
19
|
+
|
|
20
|
+
const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
|
|
21
|
+
const PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS = 1000;
|
|
22
|
+
|
|
23
|
+
function slugify(title: string): string {
|
|
24
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
25
|
+
return slug.length > 0 ? slug : "playbook";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function playbookCacheDir(): string {
|
|
29
|
+
const base = process.env["XDG_CACHE_HOME"] ?? join(homedir(), ".cache");
|
|
30
|
+
return join(base, "papyrus", "playbooks");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stringList(value: unknown): string[] {
|
|
34
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function playbookSkillMarkdown(playbook: Artifact): string {
|
|
38
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
39
|
+
const steps = stringList(playbook.extra["steps"]);
|
|
40
|
+
const tools = stringList(playbook.extra["tools"]);
|
|
41
|
+
const description = trigger.replace(/\n/g, " ").slice(0, PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS);
|
|
42
|
+
return [
|
|
43
|
+
"---",
|
|
44
|
+
`name: ${slugify(playbook.title)}`,
|
|
45
|
+
`description: ${description}`,
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
`# ${playbook.title}`,
|
|
49
|
+
"",
|
|
50
|
+
`Materialized from a live Papyrus playbook; edits here are lost on the next refresh. Edit the playbook itself instead (the playbooks tool, action=update), then /reload.`,
|
|
51
|
+
"",
|
|
52
|
+
`Trigger: ${trigger}`,
|
|
53
|
+
"",
|
|
54
|
+
...(playbook.body ? [`Context: ${playbook.body}`, ""] : []),
|
|
55
|
+
...(steps.length > 0 ? ["## Steps", "", ...steps.map((step, index) => `${index + 1}. ${step}`), ""] : []),
|
|
56
|
+
...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`, ""] : []),
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Exported for direct testing without a real ExtensionAPI. */
|
|
61
|
+
export async function materializePlaybookSkillPaths(): Promise<string[]> {
|
|
62
|
+
const dir = playbookCacheDir();
|
|
63
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
const playbooks = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
|
|
65
|
+
if (playbooks.length === 0) return [];
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const usedSlugs = new Set<string>();
|
|
68
|
+
const paths: string[] = [];
|
|
69
|
+
for (const playbook of playbooks) {
|
|
70
|
+
let slug = slugify(playbook.title);
|
|
71
|
+
if (usedSlugs.has(slug)) slug = `${slug}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
|
|
72
|
+
usedSlugs.add(slug);
|
|
73
|
+
const skillDir = join(dir, slug);
|
|
74
|
+
mkdirSync(skillDir, { recursive: true });
|
|
75
|
+
const filePath = join(skillDir, "SKILL.md");
|
|
76
|
+
writeFileSync(filePath, playbookSkillMarkdown(playbook), "utf8");
|
|
77
|
+
paths.push(filePath);
|
|
78
|
+
}
|
|
79
|
+
return paths;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerPlaybookBridge(pi: ExtensionAPI): void {
|
|
83
|
+
pi.on("resources_discover", async () => {
|
|
84
|
+
try {
|
|
85
|
+
return { skillPaths: await materializePlaybookSkillPaths() };
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
|
+
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
5
|
+
import { PLAYBOOK_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
6
|
+
import { matchArtifactByName } from "./domain-tools.ts";
|
|
7
|
+
import { callService } from "./service-client.ts";
|
|
8
|
+
|
|
9
|
+
const PLAYBOOK_COMPLETION_MAX_CANDIDATES = 100;
|
|
10
|
+
|
|
11
|
+
async function activePlaybooks(): Promise<Artifact[]> {
|
|
12
|
+
return callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_COMPLETION_MAX_CANDIDATES });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `/playbook <tab>` completions -- title-prefix match, since that's what a human actually types, not a full-text search of body content. */
|
|
16
|
+
export async function playbookArgumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
|
|
17
|
+
try {
|
|
18
|
+
const needle = argumentPrefix.trim().toLowerCase();
|
|
19
|
+
const rows = await activePlaybooks();
|
|
20
|
+
return rows
|
|
21
|
+
.filter((row) => row.title.toLowerCase().startsWith(needle))
|
|
22
|
+
.sort((a, b) => a.title.localeCompare(b.title))
|
|
23
|
+
.map((row) => ({ value: row.title, label: row.title, description: typeof row.extra["trigger"] === "string" ? row.extra["trigger"] : undefined }));
|
|
24
|
+
} catch {
|
|
25
|
+
return null; // a Papyrus daemon hiccup degrades to "no suggestions", never breaks the command line
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `/playbook <name>` (no args opens the full browser instead): resolves by exact title, then places its invocation directly in the editor -- one step, not browse-then-select-then-invoke. */
|
|
30
|
+
export async function openPlaybookByName(name: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
31
|
+
if (!name.trim()) { await showPlaybooks(ctx); return; }
|
|
32
|
+
try {
|
|
33
|
+
const id = matchArtifactByName(await activePlaybooks(), name);
|
|
34
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id });
|
|
35
|
+
ctx.ui.setEditorText(invocation);
|
|
36
|
+
ctx.ui.notify(`"${name.trim()}" invocation placed in the editor`, "info");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"];
|
|
43
|
+
|
|
44
|
+
function strings(value: unknown): string[] {
|
|
45
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function playbookRowMeta(playbook: Artifact): string {
|
|
49
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? `when ${playbook.extra["trigger"]}` : "manual invocation";
|
|
50
|
+
const tools = strings(playbook.extra["tools"]);
|
|
51
|
+
return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void> {
|
|
55
|
+
await showArtifactBrowser(ctx, {
|
|
56
|
+
kind: "playbook",
|
|
57
|
+
title: "Playbooks",
|
|
58
|
+
listOperation: "playbooks.list",
|
|
59
|
+
statusOrder: ["active", "deprecated"],
|
|
60
|
+
presentation: PLAYBOOK_STATUS_PRESENTATION,
|
|
61
|
+
rowMeta: playbookRowMeta,
|
|
62
|
+
actions: (playbook) => ["Show details", "Edit", "Invoke", "Link artifact", playbook.status === "active" ? "Disable" : "Enable"],
|
|
63
|
+
handleAction: async (choice, playbook, commandCtx) => {
|
|
64
|
+
if (choice === "Show details") {
|
|
65
|
+
await showArtifactDetails(commandCtx, playbook.id, "playbooks.show");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (choice === "Edit") {
|
|
69
|
+
const title = await commandCtx.ui.input("Title:", playbook.title);
|
|
70
|
+
if (title === undefined) return; // canceled
|
|
71
|
+
const body = await commandCtx.ui.input("Body:", playbook.body);
|
|
72
|
+
if (body === undefined) return; // canceled
|
|
73
|
+
const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", { id: playbook.id, title, body });
|
|
74
|
+
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (choice === "Invoke") {
|
|
78
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id: playbook.id });
|
|
79
|
+
commandCtx.ui.setEditorText(invocation);
|
|
80
|
+
commandCtx.ui.notify("Invocation placed in the editor", "info");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (choice === "Link artifact") {
|
|
84
|
+
const targetId = await commandCtx.ui.input("Target artifact id:", "");
|
|
85
|
+
if (!targetId) return;
|
|
86
|
+
const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS);
|
|
87
|
+
if (!relation) return;
|
|
88
|
+
await callService("graph.link", { from: playbook.id, relation, to: targetId });
|
|
89
|
+
commandCtx.ui.notify(`Linked ${playbook.id} --${relation}--> ${targetId}`, "info");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable";
|
|
93
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: playbook.id });
|
|
94
|
+
commandCtx.ui.notify(`${updated.id} \u2192 [${updated.status}]`, "info");
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -112,6 +112,13 @@ const USAGE = `Usage:
|
|
|
112
112
|
papyrus skills instantiate <template-id> [--title <title>] [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--json]
|
|
113
113
|
papyrus skills assign-project <id> [project-root] [--json]
|
|
114
114
|
papyrus skills update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
|
|
115
|
+
papyrus playbooks create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
|
|
116
|
+
papyrus playbooks list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
|
|
117
|
+
papyrus playbooks show <id> [--json]
|
|
118
|
+
papyrus playbooks invoke <id> [--json]
|
|
119
|
+
papyrus playbooks enable|disable <id> [--json]
|
|
120
|
+
papyrus playbooks assign-project <id> [project-root] [--json]
|
|
121
|
+
papyrus playbooks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
|
|
115
122
|
papyrus notes capture <request> [--title <title>] [--json]
|
|
116
123
|
papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
|
|
117
124
|
papyrus notes show <id> [--json]
|
|
@@ -769,6 +776,104 @@ export async function runRulesCli(args: string[], client: TaskCliClient, project
|
|
|
769
776
|
return json ? JSON.stringify(result) : human;
|
|
770
777
|
}
|
|
771
778
|
|
|
779
|
+
export async function runPlaybooksCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
780
|
+
const json = args.includes("--json");
|
|
781
|
+
const positional: string[] = [];
|
|
782
|
+
let title: string | undefined;
|
|
783
|
+
let body: string | undefined;
|
|
784
|
+
let trigger: string | undefined;
|
|
785
|
+
let steps: string[] | undefined;
|
|
786
|
+
let tools: string[] | undefined;
|
|
787
|
+
let labels: string[] | undefined;
|
|
788
|
+
let extra: Record<string, unknown> | undefined;
|
|
789
|
+
let status: string | undefined;
|
|
790
|
+
let text: string | undefined;
|
|
791
|
+
let limit: number | undefined;
|
|
792
|
+
let playbookProjectRoot: string | undefined;
|
|
793
|
+
for (let index = 0; index < args.length; index++) {
|
|
794
|
+
const argument = args[index]!;
|
|
795
|
+
if (argument === "--json") continue;
|
|
796
|
+
if (argument === "--title") { title = args[++index]; if (title === undefined) throw new Error("--title requires a value"); continue; }
|
|
797
|
+
if (argument === "--body") { body = args[++index]; if (body === undefined) throw new Error("--body requires a value"); continue; }
|
|
798
|
+
if (argument === "--trigger") { trigger = args[++index]; if (trigger === undefined) throw new Error("--trigger requires a value"); continue; }
|
|
799
|
+
if (argument === "--steps-json") { steps = parseJsonStringArrayFlag(args[++index], "--steps-json"); continue; }
|
|
800
|
+
if (argument === "--tools-json") { tools = parseJsonStringArrayFlag(args[++index], "--tools-json"); continue; }
|
|
801
|
+
if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
|
|
802
|
+
if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
|
|
803
|
+
if (argument === "--status") { status = args[++index]; if (!status) throw new Error("--status requires a value"); continue; }
|
|
804
|
+
if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
|
|
805
|
+
if (argument === "--project-root") { playbookProjectRoot = args[++index]; if (!playbookProjectRoot) throw new Error("--project-root requires a value"); continue; }
|
|
806
|
+
if (argument === "--limit") {
|
|
807
|
+
const value = args[++index];
|
|
808
|
+
if (!value || Number.isNaN(Number(value))) throw new Error("--limit requires a numeric value");
|
|
809
|
+
limit = Number(value);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (argument.startsWith("--")) throw new Error(`unknown playbooks option ${argument}`);
|
|
813
|
+
positional.push(argument);
|
|
814
|
+
}
|
|
815
|
+
const [action, id, second] = positional;
|
|
816
|
+
let result: unknown;
|
|
817
|
+
let human: string;
|
|
818
|
+
switch (action) {
|
|
819
|
+
case "create": {
|
|
820
|
+
if (id) throw new Error("playbooks create accepts no positional arguments");
|
|
821
|
+
if (!title) throw new Error("playbooks create requires --title");
|
|
822
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.create", { title, body, trigger, steps, tools, labels, extra, project_root: playbookProjectRoot });
|
|
823
|
+
result = artifact;
|
|
824
|
+
human = `Created playbook: ${artifactLabel(artifact)}`;
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case "list": {
|
|
828
|
+
if (id) throw new Error("playbooks list accepts no positional arguments");
|
|
829
|
+
const rows = await client.call<Record<string, unknown>, CliArtifact[]>("playbooks.list", { status, text, limit, project_root: playbookProjectRoot });
|
|
830
|
+
result = rows;
|
|
831
|
+
human = rows.length === 0 ? "No playbooks found." : rows.map((row) => artifactLabel(row)).join("\n");
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case "show": {
|
|
835
|
+
if (!id || second) throw new Error("playbooks show requires exactly one playbook id");
|
|
836
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.show", { id });
|
|
837
|
+
result = artifact;
|
|
838
|
+
human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case "invoke": {
|
|
842
|
+
if (!id || second) throw new Error("playbooks invoke requires exactly one playbook id");
|
|
843
|
+
const invocation = await client.call<Record<string, unknown>, string>("playbooks.invoke", { id });
|
|
844
|
+
result = invocation;
|
|
845
|
+
human = invocation;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
case "enable":
|
|
849
|
+
case "disable": {
|
|
850
|
+
if (!id || second) throw new Error(`playbooks ${action} requires exactly one playbook id`);
|
|
851
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>(`playbooks.${action}`, { id });
|
|
852
|
+
result = artifact;
|
|
853
|
+
human = `${artifactLabel(artifact)}`;
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
case "assign-project": {
|
|
857
|
+
if (!id || second === undefined && positional.length > 2) throw new Error("playbooks assign-project requires <id> [project-root]");
|
|
858
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.assign_project", { id, project_root: second });
|
|
859
|
+
result = artifact;
|
|
860
|
+
human = second ? `Assigned ${id} to ${second}` : `Unscoped ${id}`;
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
case "update": {
|
|
864
|
+
if (!id || second) throw new Error("playbooks update requires exactly one playbook id");
|
|
865
|
+
if (title === undefined && body === undefined && labels === undefined) throw new Error("playbooks update requires --title, --body, or --labels-json");
|
|
866
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.update", { id, title, body, labels });
|
|
867
|
+
result = artifact;
|
|
868
|
+
human = `${artifactLabel(artifact)}`;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
default:
|
|
872
|
+
throw new Error("playbooks action must be create, list, show, invoke, enable, disable, assign-project, or update");
|
|
873
|
+
}
|
|
874
|
+
return json ? JSON.stringify(result) : human;
|
|
875
|
+
}
|
|
876
|
+
|
|
772
877
|
export async function runArtifactCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
773
878
|
const json = args.includes("--json");
|
|
774
879
|
const positional: string[] = [];
|
|
@@ -1532,6 +1637,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
1532
1637
|
console.log(await runSkillCli(args.slice(1), client));
|
|
1533
1638
|
return;
|
|
1534
1639
|
}
|
|
1640
|
+
if (command === "playbooks") {
|
|
1641
|
+
const client = await connectPapyrusClient();
|
|
1642
|
+
console.log(await runPlaybooksCli(args.slice(1), client));
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1535
1645
|
if (command === "notes") {
|
|
1536
1646
|
const client = await connectPapyrusClient();
|
|
1537
1647
|
console.log(await runNoteCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 18;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -99,6 +99,7 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
|
99
99
|
*/
|
|
100
100
|
export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
101
101
|
export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
|
|
102
|
+
export const PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
102
103
|
|
|
103
104
|
/**
|
|
104
105
|
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
package/src/db.ts
CHANGED
|
@@ -253,6 +253,7 @@ INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, de
|
|
|
253
253
|
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
|
|
254
254
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
255
255
|
INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
|
|
256
|
+
INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
|
|
256
257
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
257
258
|
INSERT OR IGNORE INTO statuses VALUES ('active','doc');
|
|
258
259
|
INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
|
|
@@ -266,6 +267,8 @@ INSERT OR IGNORE INTO statuses VALUES ('active','rule');
|
|
|
266
267
|
INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
|
|
267
268
|
INSERT OR IGNORE INTO statuses VALUES ('active','skill');
|
|
268
269
|
INSERT OR IGNORE INTO statuses VALUES ('deprecated','skill');
|
|
270
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
|
|
271
|
+
INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
|
|
269
272
|
INSERT OR IGNORE INTO relation_names VALUES ('references','Source material (doc→doc, doc→task, doc→rule)');
|
|
270
273
|
INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies that (task→doc, task→rule)');
|
|
271
274
|
INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→skill)');
|
|
@@ -504,6 +507,23 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
504
507
|
`);
|
|
505
508
|
},
|
|
506
509
|
},
|
|
510
|
+
{
|
|
511
|
+
version: 18,
|
|
512
|
+
name: "playbook-kind",
|
|
513
|
+
// Playbooks (trigger/steps/tools guidance an agent reads and follows) were a subtype-less
|
|
514
|
+
// shape squeezed into the "skill" kind alongside artifact-templates and workflow blueprints
|
|
515
|
+
// -- fundamentally different mechanisms (mechanical multi-artifact instantiation) from a flat
|
|
516
|
+
// step list. Split into its own kind; only rows with no subtype move -- artifact-template and
|
|
517
|
+
// workflow rows stay exactly where they are.
|
|
518
|
+
up: (db) => {
|
|
519
|
+
db.exec(`
|
|
520
|
+
INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
|
|
521
|
+
INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
|
|
522
|
+
INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
|
|
523
|
+
UPDATE artifacts SET kind = 'playbook' WHERE kind = 'skill' AND (subtype IS NULL OR subtype = '');
|
|
524
|
+
`);
|
|
525
|
+
},
|
|
526
|
+
},
|
|
507
527
|
];
|
|
508
528
|
|
|
509
529
|
/**
|
package/src/domain-services.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ARTIFACT_LABEL_MAX_LENGTH,
|
|
5
5
|
ARTIFACT_SCOPE_MAX_ARTIFACTS,
|
|
6
6
|
ARTIFACT_TITLE_MAX_LENGTH,
|
|
7
|
+
PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
7
8
|
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
8
9
|
SKILL_INVOCATION_MAX_CALL_DEPTH,
|
|
9
10
|
SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
@@ -514,3 +515,95 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
|
|
|
514
515
|
if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
|
|
515
516
|
return artifacts.setStatus(id, target, context)!;
|
|
516
517
|
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Playbooks: a trigger and an ordered list of steps an agent reads and follows -- a completely
|
|
521
|
+
* different beast from Skills, not a subtype of one. A Skill (artifact-template or workflow) is
|
|
522
|
+
* mechanically instantiated into other artifacts; a Playbook is never instantiated, it's read
|
|
523
|
+
* and followed, and it never composes other Playbooks the way a Skill can call another Skill.
|
|
524
|
+
*/
|
|
525
|
+
export interface CreatePlaybookInput {
|
|
526
|
+
title: string;
|
|
527
|
+
body?: string;
|
|
528
|
+
trigger?: string;
|
|
529
|
+
steps?: string[];
|
|
530
|
+
tools?: string[];
|
|
531
|
+
labels?: string[];
|
|
532
|
+
extra?: Record<string, unknown>;
|
|
533
|
+
projectRoot?: string;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export type PlaybookTransition = "enable" | "disable";
|
|
537
|
+
export type UpdatePlaybookInput = UpdateContentInput;
|
|
538
|
+
|
|
539
|
+
export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreatePlaybookInput, context?: ArtifactEventContext): Artifact {
|
|
540
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
541
|
+
const playbook = artifacts.create({
|
|
542
|
+
kind: "playbook",
|
|
543
|
+
status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
|
|
544
|
+
title: input.title,
|
|
545
|
+
body: input.body,
|
|
546
|
+
labels: input.labels,
|
|
547
|
+
extra: {
|
|
548
|
+
...(input.extra ?? {}),
|
|
549
|
+
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
550
|
+
...(input.steps ? { steps: input.steps } : {}),
|
|
551
|
+
...(input.tools ? { tools: input.tools } : {}),
|
|
552
|
+
},
|
|
553
|
+
}, context);
|
|
554
|
+
scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
555
|
+
return playbook;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function listPlaybooks(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
|
|
559
|
+
return listScoped(artifacts, scopes, "playbook", filter);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function assignPlaybookProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
|
|
563
|
+
return assignArtifactProject(artifacts, scopes, id, "playbook", projectRoot);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function showPlaybook(artifacts: ArtifactStore, id: string): Artifact {
|
|
567
|
+
requireKind(artifacts, id, "playbook");
|
|
568
|
+
return artifacts.get(id, { tree: true })!;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function updatePlaybook(artifacts: ArtifactStore, id: string, input: UpdatePlaybookInput, context?: ArtifactEventContext): Artifact {
|
|
572
|
+
requireContentUpdateFields(input);
|
|
573
|
+
assertTitleBounds(input.title);
|
|
574
|
+
assertBodyBounds(input.body);
|
|
575
|
+
assertLabelsBounds(input.labels);
|
|
576
|
+
const playbook = requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
|
|
577
|
+
const updated = artifacts.updateContent(playbook.id, input, context);
|
|
578
|
+
if (!updated) throw new Error(`playbook "${id}" not found`);
|
|
579
|
+
return updated;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function transitionPlaybook(artifacts: ArtifactStore, id: string, action: PlaybookTransition, context?: ArtifactEventContext): Artifact {
|
|
583
|
+
const playbook = requireKind(artifacts, id, "playbook");
|
|
584
|
+
const expected = action === "enable" ? "deprecated" : "active";
|
|
585
|
+
const target = action === "enable" ? "active" : "deprecated";
|
|
586
|
+
if (playbook.status !== expected) throw new Error(`cannot ${action} playbook from ${playbook.status}`);
|
|
587
|
+
return artifacts.setStatus(id, target, context)!;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Renders trigger/steps/tools into readable guidance, plus any real linked artifacts. No nested playbook-calls-playbook composition -- a Playbook is a flat procedure, not a composable bundle. */
|
|
591
|
+
export function playbookInvocation(artifacts: ArtifactStore, id: string): string {
|
|
592
|
+
const playbook = requireKind(artifacts, id, "playbook");
|
|
593
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
594
|
+
const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
|
595
|
+
const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
|
|
596
|
+
const sections = [[
|
|
597
|
+
`Apply Papyrus playbook "${playbook.title}" (${playbook.id}).`,
|
|
598
|
+
`Trigger: ${trigger}`,
|
|
599
|
+
...(playbook.body ? [`Context: ${playbook.body}`] : []),
|
|
600
|
+
...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
601
|
+
...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
|
|
602
|
+
].join("\n")];
|
|
603
|
+
const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
|
|
604
|
+
const linkedLines = edges
|
|
605
|
+
.map((edge) => { const target = artifacts.get(edge.to); return target ? `- ${edge.relation} ${target.kind} "${target.title}" (${target.id})` : undefined; })
|
|
606
|
+
.filter((line): line is string => line !== undefined);
|
|
607
|
+
if (linkedLines.length > 0) sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedLines].join("\n"));
|
|
608
|
+
return sections.join("\n\n");
|
|
609
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/playbooks.ts — Playbooks as a Papyrus-native registered module.
|
|
3
|
+
*
|
|
4
|
+
* A Playbook (trigger + ordered steps an agent reads and follows) is a completely different
|
|
5
|
+
* beast from a Skill (a mechanically instantiated artifact-template or workflow blueprint) --
|
|
6
|
+
* its own kind, not a subtype squeezed into "skill". See domain-services.ts's Playbook section
|
|
7
|
+
* for the full rationale.
|
|
8
|
+
*/
|
|
9
|
+
import { assignPlaybookProject, createPlaybook, listPlaybooks, playbookInvocation, showPlaybook, transitionPlaybook, updatePlaybook } from "../domain-services.ts";
|
|
10
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
11
|
+
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
12
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
+
|
|
14
|
+
const MODULE_ID = "playbooks";
|
|
15
|
+
|
|
16
|
+
type OperationInput = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
function string(input: OperationInput, key: string): string {
|
|
19
|
+
const value = input[key];
|
|
20
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
25
|
+
const value = input[key];
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
32
|
+
const value = input[key];
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const eventContext = (input: OperationInput) => ({
|
|
39
|
+
actor: optionalString(input, "actor"),
|
|
40
|
+
source: optionalString(input, "source"),
|
|
41
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const artifactFilter = (input: OperationInput) => ({
|
|
45
|
+
status: optionalString(input, "status"),
|
|
46
|
+
text: optionalString(input, "text"),
|
|
47
|
+
limit: optionalNumber(input, "limit"),
|
|
48
|
+
projectRoot: optionalString(input, "project_root"),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
|
|
52
|
+
export const PLAYBOOKS_OPERATION_NAMES = [
|
|
53
|
+
"playbooks.create", "playbooks.list", "playbooks.show", "playbooks.invoke", "playbooks.enable", "playbooks.disable", "playbooks.assign_project", "playbooks.update",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
|
|
57
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
58
|
+
name, moduleId: MODULE_ID, execute,
|
|
59
|
+
});
|
|
60
|
+
return [
|
|
61
|
+
define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, scopes, {
|
|
62
|
+
title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
|
|
63
|
+
steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
|
|
64
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
65
|
+
projectRoot: optionalString(input, "project_root"),
|
|
66
|
+
}, eventContext(input))),
|
|
67
|
+
define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, scopes, artifactFilter(input))),
|
|
68
|
+
define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
|
|
69
|
+
define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"))),
|
|
70
|
+
define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
|
|
71
|
+
define("playbooks.disable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "disable", eventContext(input))),
|
|
72
|
+
define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
|
|
73
|
+
define("playbooks.update", (input: OperationInput) => updatePlaybook(artifacts, string(input, "id"), {
|
|
74
|
+
title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
|
|
75
|
+
}, eventContext(input))),
|
|
76
|
+
];
|
|
77
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
|
|
|
33
33
|
import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
34
34
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
35
35
|
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
36
|
+
import { playbooksOperations, PLAYBOOKS_OPERATION_NAMES } from "./modules/playbooks.ts";
|
|
36
37
|
import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
|
|
37
38
|
import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
|
|
38
39
|
import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
@@ -73,6 +74,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
73
74
|
...NOTES_OPERATION_NAMES,
|
|
74
75
|
...RULES_OPERATION_NAMES,
|
|
75
76
|
...SKILLS_OPERATION_NAMES,
|
|
77
|
+
...PLAYBOOKS_OPERATION_NAMES,
|
|
76
78
|
...GRAPH_PROJECTION_OPERATION_NAMES,
|
|
77
79
|
...LOGS_OPERATION_NAMES,
|
|
78
80
|
...SESSION_IDENTITY_OPERATION_NAMES,
|
|
@@ -368,6 +370,14 @@ function handlers(
|
|
|
368
370
|
"skills.disable": forwardToModule("skills.disable"),
|
|
369
371
|
"skills.assign_project": forwardToModule("skills.assign_project"),
|
|
370
372
|
"skills.update": forwardToModule("skills.update"),
|
|
373
|
+
"playbooks.create": forwardToModule("playbooks.create"),
|
|
374
|
+
"playbooks.list": forwardToModule("playbooks.list"),
|
|
375
|
+
"playbooks.show": forwardToModule("playbooks.show"),
|
|
376
|
+
"playbooks.invoke": forwardToModule("playbooks.invoke"),
|
|
377
|
+
"playbooks.enable": forwardToModule("playbooks.enable"),
|
|
378
|
+
"playbooks.disable": forwardToModule("playbooks.disable"),
|
|
379
|
+
"playbooks.assign_project": forwardToModule("playbooks.assign_project"),
|
|
380
|
+
"playbooks.update": forwardToModule("playbooks.update"),
|
|
371
381
|
"skills.instantiate": (input) => {
|
|
372
382
|
const templateId = string(input, "template_id");
|
|
373
383
|
const template = artifacts.get(templateId);
|
|
@@ -429,6 +439,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
429
439
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
430
440
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
431
441
|
moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
|
|
442
|
+
moduleRegistry.registerAll(playbooksOperations(artifacts, artifactScopes));
|
|
432
443
|
moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
|
|
433
444
|
const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
|
|
434
445
|
const state = (): SchemaState => {
|