@danypops/papyrus 0.21.6 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,9 +119,10 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
119
119
 
120
120
  - **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
121
121
  - **`notes`** — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
122
- - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
123
- - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
124
- - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
122
+ - **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
123
+ - **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
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
 
@@ -131,14 +132,19 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
131
132
 
132
133
  Every agent domain tool (tasks, docs, rules, skills, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), `template_name` (skills instantiate), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
133
134
 
135
+ ### Mutability
136
+
137
+ Tasks, Docs, Rules, and Skills all support first-class `update` (title/body/labels, at least one required) alongside creation -- a Doc's body is no longer immutable once created. Every update is bounded the same way creation is (Rules keep their own stricter combined condition+action+body ceiling; Docs/Skills share Tasks' own length bounds) and recorded on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider` on an ingested page) is a read-only projection from a system Papyrus doesn't own the source of; updating one is refused with a clear error rather than silently forking it -- capture a correction as a new linked Doc instead until a write-back capability to that system exists. Notes stay behind their own facade for any content change, same as every other Notes mutation.
138
+
134
139
  ## Interactive frontends
135
140
 
136
141
  - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
137
142
  - `/note <request>` — directly capture one project-scoped deferred request without creating a Task
138
143
  - `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
139
- - `/docs` — searchable non-Note documents, lifecycle, details, and graph links
140
- - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
141
- - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
144
+ - `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
145
+ - `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
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
142
148
 
143
149
  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.
144
150
 
@@ -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
@@ -6,12 +6,12 @@
6
6
  * anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so this is a small,
7
7
  * genuinely domain-specific checkbox-list component, not a generic library replacement.
8
8
  */
9
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
11
11
  import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
12
12
 
13
13
  /** Toggle with space, confirm with enter (refuses an empty confirm -- at least one pick is required), cancel with escape. */
14
- async function pickMultiple(ctx: ExtensionCommandContext, title: string, options: string[]): Promise<string[] | undefined> {
14
+ async function pickMultiple(ctx: ExtensionContext, title: string, options: string[]): Promise<string[] | undefined> {
15
15
  return ctx.ui.custom<string[] | undefined>((tui, theme, _keybindings, done) => {
16
16
  const checked = new Set<number>();
17
17
  let selectedIndex = 0;
@@ -49,8 +49,13 @@ async function pickMultiple(ctx: ExtensionCommandContext, title: string, options
49
49
  });
50
50
  }
51
51
 
52
- /** Picks one (single) or several (multi) of the given options, or undefined if the user cancels. */
53
- export async function pickDiscussionOptions(ctx: ExtensionCommandContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
52
+ /**
53
+ * Picks one (single) or several (multi) of the given options, or undefined if the user cancels.
54
+ * Takes the base ExtensionContext (just .ui) rather than the wider ExtensionCommandContext, since
55
+ * a tool's execute() only ever receives the former -- the discuss tool's own live mode reuses
56
+ * this same picker, not just the /discuss TUI panel.
57
+ */
58
+ export async function pickDiscussionOptions(ctx: ExtensionContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
54
59
  if (mode === "single") {
55
60
  const pick = await ctx.ui.select("Pick one:", options);
56
61
  return pick ? [pick] : undefined;
@@ -24,12 +24,21 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
24
24
  statusOrder: ["draft", "active", "archived"],
25
25
  presentation: DOC_STATUS_PRESENTATION,
26
26
  rowMeta: documentRowMeta,
27
- actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
27
+ actions: (document) => ["Show details", "Edit", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
28
28
  handleAction: async (choice, document, commandCtx) => {
29
29
  if (choice === "Show details") {
30
30
  await showArtifactDetails(commandCtx, document.id, "docs.show");
31
31
  return;
32
32
  }
33
+ if (choice === "Edit") {
34
+ const title = await commandCtx.ui.input("Title:", document.title);
35
+ if (title === undefined) return; // canceled
36
+ const body = await commandCtx.ui.input("Body:", document.body);
37
+ if (body === undefined) return; // canceled
38
+ const updated = await callService<Record<string, unknown>, Artifact>("docs.update", { id: document.id, title, body });
39
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
40
+ return;
41
+ }
33
42
  if (choice === "Link artifact") {
34
43
  const targetId = await commandCtx.ui.input("Target artifact id:", "");
35
44
  if (!targetId) return;
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { Artifact } from "../../src/domain/artifact.ts";
4
4
  import { PROOF_TYPES } from "../../src/domain/checklist.ts";
@@ -8,7 +8,8 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
8
8
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
11
- import type { DiscussionRound } from "../../src/domain/discussion.ts";
11
+ import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
12
+ import { pickDiscussionOptions } from "./discussion-picker.ts";
12
13
  import type { OperationName } from "../../src/service.ts";
13
14
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
14
15
  import { sessionSecretField } from "./session-identity.ts";
@@ -30,6 +31,24 @@ function text(message: string, details: unknown = {}) {
30
31
  return { content: [{ type: "text" as const, text: modelContent.text }], details };
31
32
  }
32
33
 
34
+ /**
35
+ * live:true's synchronous half: renders the same picker /discuss's own "Reply" action uses when
36
+ * the just-created round posed a structured choice, or a plain freeform prompt otherwise -- so
37
+ * "ask" covers both a completely open question and a choice tied to this specific Discussion.
38
+ * Returns undefined on cancel or when no interactive UI is available, never throws -- an
39
+ * unanswered live prompt still leaves the round it already recorded intact.
40
+ */
41
+ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<{ content: string; selected?: string[] } | undefined> {
42
+ if (!ctx.hasUI) return undefined;
43
+ const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
44
+ if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
45
+ const selected = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
46
+ return selected ? { content: selected.join(", "), selected } : undefined;
47
+ }
48
+ const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
49
+ return content ? { content } : undefined;
50
+ }
51
+
33
52
  /**
34
53
  * Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
35
54
  * backend implementation detail (a stable key other operations need, and titles aren't
@@ -397,7 +416,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
397
416
  pi.registerTool({
398
417
  name: "docs",
399
418
  label: "Documents",
400
- description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
419
+ description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, 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. update changes title/body/labels (at least one required) and is refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
401
420
  parameters: Type.Object({
402
421
  action: Type.String(),
403
422
  id: Type.Optional(Type.String()),
@@ -443,7 +462,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
443
462
  }
444
463
  const trashResult = await handleArtifactRemoveRestore(action, params);
445
464
  if (trashResult) return trashResult;
446
- const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
465
+ const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project", update: "docs.update" } as const;
447
466
  const operation = operations[action as keyof typeof operations];
448
467
  if (!operation) throw new Error(`unknown docs action: ${action}`);
449
468
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -457,7 +476,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
457
476
  pi.registerTool({
458
477
  name: "rules",
459
478
  label: "Rules",
460
- description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
479
+ description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, 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. Active rules inject into the agent system prompt. update changes title/body/labels (at least one required); body updates still enforce the same combined condition+action+body context-tax bound as creation, and are refused for a read-only external projection. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
461
480
  parameters: Type.Object({
462
481
  action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
463
482
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
@@ -491,7 +510,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
491
510
  }
492
511
  const trashResult = await handleArtifactRemoveRestore(action, params);
493
512
  if (trashResult) return trashResult;
494
- const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
513
+ const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project", update: "rules.update" } as const;
495
514
  const operation = operations[action as keyof typeof operations];
496
515
  if (!operation) throw new Error(`unknown rules action: ${action}`);
497
516
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -502,10 +521,56 @@ export function registerDomainTools(pi: ExtensionAPI): void {
502
521
  },
503
522
  });
504
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
+
505
570
  pi.registerTool({
506
571
  name: "skills",
507
572
  label: "Skills",
508
- description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
573
+ description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, update, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
509
574
  parameters: Type.Object({
510
575
  action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
511
576
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
@@ -570,7 +635,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
570
635
  }
571
636
  const trashResult = await handleArtifactRemoveRestore(action, params);
572
637
  if (trashResult) return trashResult;
573
- const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
638
+ const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
574
639
  const operation = operations[action as keyof typeof operations];
575
640
  if (!operation) throw new Error(`unknown skills action: ${action}`);
576
641
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
@@ -584,7 +649,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
584
649
  pi.registerTool({
585
650
  name: "discuss",
586
651
  label: "Discuss",
587
- description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
652
+ description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
588
653
  parameters: Type.Object({
589
654
  action: Type.String(),
590
655
  id: Type.Optional(Type.String()),
@@ -606,6 +671,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
606
671
  options: Type.Optional(Type.Array(Type.String())),
607
672
  options_mode: Type.Optional(Type.String()),
608
673
  selected: Type.Optional(Type.Array(Type.String())),
674
+ live: Type.Optional(Type.Boolean()),
609
675
  }),
610
676
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
611
677
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -619,13 +685,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
619
685
  { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
620
686
  ]);
621
687
  await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
622
- if (action === "open") {
623
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.open", params);
624
- return text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion));
625
- }
626
- if (action === "reply") {
627
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", params);
628
- return text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
688
+ if (action === "open" || action === "reply") {
689
+ const operation = action === "open" ? "discuss.open" : "discuss.reply";
690
+ const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
691
+ const fallback = action === "open"
692
+ ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
693
+ : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
694
+ if (params.live !== true) return fallback;
695
+ const answer = await liveAnswer(ctx, result.discussion);
696
+ if (!answer) return fallback;
697
+ const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
698
+ id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
699
+ });
700
+ return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
629
701
  }
630
702
  if (action === "block" || action === "unblock") {
631
703
  const operation = action === "block" ? "discuss.block" : "discuss.unblock";
@@ -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,10 @@ 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
+ });
458
465
  pi.registerCommand("discuss", {
459
466
  description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
460
467
  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,62 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Artifact } from "../../src/domain/artifact.ts";
3
+ import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
+ import { PLAYBOOK_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
5
+ import { callService } from "./service-client.ts";
6
+
7
+ const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"];
8
+
9
+ function strings(value: unknown): string[] {
10
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
11
+ }
12
+
13
+ export function playbookRowMeta(playbook: Artifact): string {
14
+ const trigger = typeof playbook.extra["trigger"] === "string" ? `when ${playbook.extra["trigger"]}` : "manual invocation";
15
+ const tools = strings(playbook.extra["tools"]);
16
+ return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 ");
17
+ }
18
+
19
+ export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void> {
20
+ await showArtifactBrowser(ctx, {
21
+ kind: "playbook",
22
+ title: "Playbooks",
23
+ listOperation: "playbooks.list",
24
+ statusOrder: ["active", "deprecated"],
25
+ presentation: PLAYBOOK_STATUS_PRESENTATION,
26
+ rowMeta: playbookRowMeta,
27
+ actions: (playbook) => ["Show details", "Edit", "Invoke", "Link artifact", playbook.status === "active" ? "Disable" : "Enable"],
28
+ handleAction: async (choice, playbook, commandCtx) => {
29
+ if (choice === "Show details") {
30
+ await showArtifactDetails(commandCtx, playbook.id, "playbooks.show");
31
+ return;
32
+ }
33
+ if (choice === "Edit") {
34
+ const title = await commandCtx.ui.input("Title:", playbook.title);
35
+ if (title === undefined) return; // canceled
36
+ const body = await commandCtx.ui.input("Body:", playbook.body);
37
+ if (body === undefined) return; // canceled
38
+ const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", { id: playbook.id, title, body });
39
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
40
+ return;
41
+ }
42
+ if (choice === "Invoke") {
43
+ const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id: playbook.id });
44
+ commandCtx.ui.setEditorText(invocation);
45
+ commandCtx.ui.notify("Invocation placed in the editor", "info");
46
+ return;
47
+ }
48
+ if (choice === "Link artifact") {
49
+ const targetId = await commandCtx.ui.input("Target artifact id:", "");
50
+ if (!targetId) return;
51
+ const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS);
52
+ if (!relation) return;
53
+ await callService("graph.link", { from: playbook.id, relation, to: targetId });
54
+ commandCtx.ui.notify(`Linked ${playbook.id} --${relation}--> ${targetId}`, "info");
55
+ return;
56
+ }
57
+ const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable";
58
+ const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: playbook.id });
59
+ commandCtx.ui.notify(`${updated.id} \u2192 [${updated.status}]`, "info");
60
+ },
61
+ });
62
+ }
@@ -25,10 +25,17 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
25
25
  statusOrder: ["active", "deprecated"],
26
26
  presentation: RULE_STATUS_PRESENTATION,
27
27
  rowMeta: ruleRowMeta,
28
- actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
28
+ actions: (rule) => ["Show details", "Edit", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
29
29
  handleAction: async (choice, rule, commandCtx) => {
30
30
  if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
31
- else if (choice === "Preview injection") {
31
+ else if (choice === "Edit") {
32
+ const title = await commandCtx.ui.input("Title:", rule.title);
33
+ if (title === undefined) return; // canceled
34
+ const body = await commandCtx.ui.input("Body:", rule.body);
35
+ if (body === undefined) return; // canceled
36
+ const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
37
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
38
+ } else if (choice === "Preview injection") {
32
39
  const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
33
40
  commandCtx.ui.notify(preview, "info");
34
41
  } else if (choice === "Link gated task") {
@@ -76,12 +76,20 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
76
76
  rowMeta: skillRowMeta,
77
77
  actions: (skill) => [
78
78
  "Show details",
79
+ "Edit",
79
80
  skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
80
81
  skill.status === "active" ? "Disable" : "Enable",
81
82
  ],
82
83
  handleAction: async (choice, skill, commandCtx) => {
83
84
  if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
84
- else if (choice === "Run workflow") {
85
+ else if (choice === "Edit") {
86
+ const title = await commandCtx.ui.input("Title:", skill.title);
87
+ if (title === undefined) return; // canceled
88
+ const body = await commandCtx.ui.input("Body:", skill.body);
89
+ if (body === undefined) return; // canceled
90
+ const updated = await callService<Record<string, unknown>, Artifact>("skills.update", { id: skill.id, title, body });
91
+ commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
92
+ } else if (choice === "Run workflow") {
85
93
  const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
86
94
  if (source === undefined) return;
87
95
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.21.6",
3
+ "version": "0.23.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -22,7 +22,7 @@ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
22
22
  import type { Artifact, ArtifactLink } from "./domain/artifact.ts";
23
23
  import type { ArtifactStore } from "./ports/artifact-store.ts";
24
24
 
25
- export type ArtifactAction = "create" | "link" | "status";
25
+ export type ArtifactAction = "create" | "link" | "status" | "update";
26
26
 
27
27
  export interface AuthorityClaim {
28
28
  /** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */