@danypops/papyrus 0.23.0 → 0.25.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
@@ -122,7 +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
+ - **`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. A Playbook can declare named arguments (`{name, description?, required?}`, required defaults true); invoking with some unsupplied lists exactly which required ones are still missing and directs the agent to ask via `discuss` with `live:true` rather than guess
126
126
 
127
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.
128
128
 
@@ -145,6 +145,7 @@ Tasks, Docs, Rules, and Skills all support first-class `update` (title/body/labe
145
145
  - `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
146
146
  - `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
147
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
148
149
 
149
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.
150
151
 
@@ -174,7 +175,7 @@ Blocking is real: `tasks.complete` is refused while any `active` Discussion has
174
175
 
175
176
  `open`/`reply` can also pose a structured choice instead of (or alongside) free text: `options` (2-10 entries) plus `options_mode` -- `single` is mutually exclusive (exactly one pick), `multi` allows several. The Discussion remembers the pending choice (`extra.discussion.pendingOptions`/`pendingOptionsMode`) until a `reply` answers it with `selected`, validated against exactly what was offered and the mode's cardinality; a reply can also pose the *next* round's choice in the same call.
176
177
 
177
- Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select (space to toggle, enter to confirm) for `multi`, since no built-in multi-select exists in the Pi extension UI. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
178
+ Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select for `multi`, since no built-in multi-select exists in the Pi extension UI. Both modes append a numbered "type your own answer" row -- a genuinely open answer is exactly as valid as any posed option. The multi-select picker supports a number key as a direct quick-select (jump straight to that row instead of scrolling), and steadily highlights checked rows while dimming the rest so the eye reads "what's chosen" independent of cursor position; its cursor row blinks to mark focus. It also auto-cancels after 30s of zero input -- the very first keystroke of any kind stops that countdown permanently for that prompt. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
178
179
 
179
180
  ```bash
180
181
  papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
@@ -83,8 +83,14 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
83
83
  if (choice === "Reply") {
84
84
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
85
85
  if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
86
- const selected = await pickDiscussionOptions(commandCtx, pending.pendingOptionsMode, pending.pendingOptions);
87
- if (!selected) return; // canceled
86
+ const result = await pickDiscussionOptions(commandCtx, pending.pendingOptionsMode, pending.pendingOptions);
87
+ if (!result) return; // canceled
88
+ if (result.kind === "freeform") {
89
+ await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: result.text, source: SOURCE });
90
+ commandCtx.ui.notify("Reply added.", "info");
91
+ return;
92
+ }
93
+ const { selected } = result;
88
94
  const elaboration = await commandCtx.ui.input("Elaborate (optional):", selected.join(", "));
89
95
  if (elaboration === undefined) return; // canceled
90
96
  await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: elaboration || selected.join(", "), selected, source: SOURCE });
@@ -1,47 +1,118 @@
1
1
  /**
2
- * discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action.
2
+ * discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action and the
3
+ * discuss tool's own live:true synchronous ask.
3
4
  *
4
- * "single" mode (mutually exclusive) needs nothing bespoke: the Pi extension UI already
5
- * provides exactly that (ctx.ui.select). "multi" (allow several) has no native equivalent
6
- * anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so this is a small,
7
- * genuinely domain-specific checkbox-list component, not a generic library replacement.
5
+ * "single" mode (mutually exclusive) needs nothing bespoke for the pick list itself: the Pi
6
+ * extension UI already provides exactly that (ctx.ui.select). "multi" (allow several) has no
7
+ * native equivalent anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so
8
+ * that one is a small, genuinely domain-specific checkbox-list component, not a generic library
9
+ * replacement. Both modes get a numbered quick-select (press the row's digit instead of
10
+ * scrolling with arrows) and an appended "type your own answer" row, itself numbered the same
11
+ * way -- a genuinely open question is exactly as valid an answer as any of the posed options.
8
12
  */
9
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
14
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
15
+ import { DISCUSSION_PICKER_IDLE_TIMEOUT_MS, DISCUSSION_PICKER_TICK_MS } from "../../src/constants.ts";
11
16
  import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
12
17
 
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: ExtensionContext, title: string, options: string[]): Promise<string[] | undefined> {
15
- return ctx.ui.custom<string[] | undefined>((tui, theme, _keybindings, done) => {
18
+ const FREEFORM_LABEL = "Something else (type your own answer)";
19
+
20
+ export type DiscussionPickResult = { kind: "selected"; selected: string[] } | { kind: "freeform"; text: string };
21
+
22
+ /** digit "1".."9" -> index 0-8, "0" -> index 9 (DISCUSSION_OPTIONS_MAX_COUNT is 10) -- standard terminal-menu numbering, not 0-indexed. */
23
+ function digitToIndex(data: string, rowCount: number): number | undefined {
24
+ if (data === "0") return rowCount >= 10 ? 9 : undefined;
25
+ if (data.length === 1 && data >= "1" && data <= "9") {
26
+ const index = Number(data) - 1;
27
+ return index < rowCount ? index : undefined;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function rowNumberLabel(index: number): string {
33
+ if (index < 9) return `${index + 1}`;
34
+ if (index === 9) return "0";
35
+ return " "; // beyond the 1-9,0 quick-select range (should not happen given DISCUSSION_OPTIONS_MAX_COUNT=10, but never crash rendering)
36
+ }
37
+
38
+ async function promptFreeformAnswer(ctx: ExtensionContext): Promise<DiscussionPickResult | undefined> {
39
+ const text = await ctx.ui.input("Your answer:", "");
40
+ return text ? { kind: "freeform", text } : undefined;
41
+ }
42
+
43
+ /**
44
+ * Toggle with space or a row's number, confirm with enter (refuses an empty confirm -- at least
45
+ * one pick is required), cancel with escape. Picking the freeform row exits the checkbox flow
46
+ * entirely rather than adding it to the selection.
47
+ *
48
+ * Idle countdown: auto-cancels after DISCUSSION_PICKER_IDLE_TIMEOUT_MS of no input at all --
49
+ * the first keystroke of any kind stops it permanently (not a pause; it never resumes for this
50
+ * picker instance), since a countdown ticking while someone is actively engaging is pressure,
51
+ * not a nudge. The same tick also drives a slow, deliberately noticeable blink on the cursor
52
+ * row -- checked rows stay steadily highlighted, everything else stays dimmed, so the eye reads
53
+ * "what's chosen" at a glance independent of where the cursor happens to be.
54
+ */
55
+ async function pickMultiple(ctx: ExtensionContext, title: string, options: string[], allowFreeform: boolean, idleTimeoutMs: number, tickMs: number): Promise<DiscussionPickResult | undefined> {
56
+ return ctx.ui.custom<DiscussionPickResult | undefined>((tui, theme, _keybindings, done) => {
57
+ const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
58
+ const freeformIndex = allowFreeform ? rows.length - 1 : -1;
16
59
  const checked = new Set<number>();
17
60
  let selectedIndex = 0;
61
+ let hasInteracted = false;
62
+ let remainingMs = idleTimeoutMs;
63
+ let blinkOn = true;
64
+ const tick = setInterval(() => {
65
+ blinkOn = !blinkOn;
66
+ if (!hasInteracted) {
67
+ remainingMs -= tickMs;
68
+ if (remainingMs <= 0) { finish(undefined); return; }
69
+ }
70
+ tui.requestRender();
71
+ }, tickMs);
72
+ const finish = (value: DiscussionPickResult | undefined) => { clearInterval(tick); done(value); };
73
+ const chooseFreeform = () => { promptFreeformAnswer(ctx).then(finish); };
74
+ const toggle = (index: number) => {
75
+ if (index === freeformIndex) { chooseFreeform(); return; }
76
+ if (checked.has(index)) checked.delete(index); else checked.add(index);
77
+ tui.requestRender();
78
+ };
18
79
  return {
19
80
  invalidate() {},
20
81
  render(width: number): string[] {
21
82
  const lines: string[] = [
22
83
  theme.bold(title),
23
- theme.fg("muted", "space toggle \u00b7 enter confirm \u00b7 esc cancel"),
84
+ theme.fg("muted", "number/space toggle \u00b7 enter confirm \u00b7 esc cancel"),
24
85
  "",
25
86
  ];
26
- options.forEach((option, index) => {
27
- const cursor = index === selectedIndex ? theme.fg("accent", "\u276f") : " ";
28
- const box = checked.has(index) ? theme.fg("success", "[x]") : "[ ]";
29
- const label = index === selectedIndex ? theme.bold(option) : option;
30
- lines.push(truncateToWidth(`${cursor} ${box} ${label}`, width, ""));
87
+ rows.forEach((option, index) => {
88
+ const isCursor = index === selectedIndex;
89
+ const isChecked = checked.has(index);
90
+ const cursorGlyph = isCursor && blinkOn ? theme.fg("accent", "\u276f") : " ";
91
+ const box = index === freeformIndex ? " " : isChecked ? theme.fg("success", "[x]") : "[ ]";
92
+ let label = option;
93
+ if (isChecked) label = theme.bold(theme.fg("success", label));
94
+ else if (isCursor) label = theme.bold(theme.fg("accent", label));
95
+ else label = theme.fg("dim", label);
96
+ lines.push(truncateToWidth(`${cursorGlyph} ${rowNumberLabel(index)}. ${box} ${label}`, width, ""));
31
97
  });
32
98
  lines.push("");
33
99
  lines.push(theme.fg("dim", `${checked.size} selected`));
100
+ if (!hasInteracted) lines.push(theme.fg("dim", `auto-cancels in ${Math.max(0, Math.ceil(remainingMs / 1000))}s (press any key to stop)`));
34
101
  return lines;
35
102
  },
36
103
  handleInput(data: string) {
37
- if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + options.length) % options.length;
38
- else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % options.length;
39
- else if (data === " ") { if (checked.has(selectedIndex)) checked.delete(selectedIndex); else checked.add(selectedIndex); }
104
+ hasInteracted = true;
105
+ const digit = digitToIndex(data, rows.length);
106
+ if (digit !== undefined) { selectedIndex = digit; toggle(digit); return; }
107
+ if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + rows.length) % rows.length;
108
+ else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % rows.length;
109
+ else if (data === " ") { toggle(selectedIndex); return; }
40
110
  else if (matchesKey(data, "enter")) {
111
+ if (selectedIndex === freeformIndex && checked.size === 0) { chooseFreeform(); return; }
41
112
  if (checked.size === 0) return; // refuse an empty confirm -- selecting nothing isn't a valid answer
42
- done([...checked].sort((a, b) => a - b).map((index) => options[index]!));
113
+ finish({ kind: "selected", selected: [...checked].sort((a, b) => a - b).map((index) => rows[index]!) });
43
114
  return;
44
- } else if (matchesKey(data, "escape")) { done(undefined); return; }
115
+ } else if (matchesKey(data, "escape")) { finish(undefined); return; }
45
116
  else return;
46
117
  tui.requestRender();
47
118
  },
@@ -50,15 +121,28 @@ async function pickMultiple(ctx: ExtensionContext, title: string, options: strin
50
121
  }
51
122
 
52
123
  /**
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.
124
+ * Picks one (single) or several (multi) of the given options, or a freeform typed answer
125
+ * instead, or undefined if the user cancels. Takes the base ExtensionContext (just .ui) rather
126
+ * than the wider ExtensionCommandContext, since a tool's execute() only ever receives the
127
+ * former -- the discuss tool's own live mode reuses this same picker, not just the /discuss
128
+ * TUI panel. Single mode's numbered quick-select is not guaranteed: it delegates to Pi's own
129
+ * native ctx.ui.select, which this package does not control the key handling of.
57
130
  */
58
- export async function pickDiscussionOptions(ctx: ExtensionContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
131
+ export async function pickDiscussionOptions(
132
+ ctx: ExtensionContext,
133
+ mode: DiscussionOptionsMode,
134
+ options: string[],
135
+ allowFreeform = true,
136
+ /** Test seam: real timers, not faked global time -- pass tiny values to exercise the idle-cancel/blink logic quickly and deterministically. */
137
+ idleTimeoutMs = DISCUSSION_PICKER_IDLE_TIMEOUT_MS,
138
+ tickMs = DISCUSSION_PICKER_TICK_MS,
139
+ ): Promise<DiscussionPickResult | undefined> {
59
140
  if (mode === "single") {
60
- const pick = await ctx.ui.select("Pick one:", options);
61
- return pick ? [pick] : undefined;
141
+ const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
142
+ const pick = await ctx.ui.select("Pick one:", rows);
143
+ if (!pick) return undefined;
144
+ if (allowFreeform && pick === FREEFORM_LABEL) return promptFreeformAnswer(ctx);
145
+ return { kind: "selected", selected: [pick] };
62
146
  }
63
- return pickMultiple(ctx, "Pick one or more:", options);
147
+ return pickMultiple(ctx, "Pick one or more:", options, allowFreeform, idleTimeoutMs, tickMs);
64
148
  }
@@ -42,8 +42,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<
42
42
  if (!ctx.hasUI) return undefined;
43
43
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
44
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;
45
+ const result = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
46
+ if (!result) return undefined;
47
+ if (result.kind === "freeform") return { content: result.text };
48
+ return { content: result.selected.join(", "), selected: result.selected };
47
49
  }
48
50
  const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
49
51
  return content ? { content } : undefined;
@@ -524,11 +526,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
524
526
  pi.registerTool({
525
527
  name: "playbooks",
526
528
  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.",
529
+ 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. On create, `arguments` declares named inputs the Playbook needs: [{name, description?, required?}] (required defaults true). On invoke, `arguments` supplies known values as {name: value}; invoke renders which declared REQUIRED arguments are still missing and directs you to ask the human for them via the discuss tool with live:true -- never guess or invent a value for a missing required argument. 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
530
  parameters: Type.Object({
529
531
  action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
530
532
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
531
533
  tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
534
+ arguments: Type.Optional(Type.Unknown()),
532
535
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
533
536
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
534
537
  project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
@@ -462,6 +462,11 @@ export default async function (pi: ExtensionAPI) {
462
462
  description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
463
463
  handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
464
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
+ });
465
470
  pi.registerCommand("discuss", {
466
471
  description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
467
472
  handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
@@ -1,9 +1,44 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
1
2
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
3
  import type { Artifact } from "../../src/domain/artifact.ts";
3
4
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
5
  import { PLAYBOOK_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
6
+ import { matchArtifactByName } from "./domain-tools.ts";
5
7
  import { callService } from "./service-client.ts";
6
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
+
7
42
  const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"];
8
43
 
9
44
  function strings(value: unknown): string[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.23.0",
3
+ "version": "0.25.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"],
package/src/constants.ts CHANGED
@@ -100,6 +100,9 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
100
100
  export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
101
101
  export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
102
102
  export const PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
103
+ export const PLAYBOOK_ARGUMENT_MAX_COUNT = 20;
104
+ export const PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH = 64;
105
+ export const PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH = 500;
103
106
 
104
107
  /**
105
108
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
@@ -172,6 +175,9 @@ export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
172
175
  export const DISCUSSION_OPTIONS_MIN_COUNT = 2;
173
176
  export const DISCUSSION_OPTIONS_MAX_COUNT = 10;
174
177
  export const DISCUSSION_OPTION_MAX_LENGTH = 200;
178
+ /** The multi-select picker's idle auto-cancel countdown and its render tick (also drives the cursor-row blink). Single-select has no equivalent -- it delegates to Pi's own native ctx.ui.select, whose input loop this package does not control. */
179
+ export const DISCUSSION_PICKER_IDLE_TIMEOUT_MS = 30_000;
180
+ export const DISCUSSION_PICKER_TICK_MS = 500;
175
181
  /** Bounds for the generic graph projection protocol (external bounded contexts). */
176
182
  export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
177
183
  export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
@@ -4,6 +4,9 @@ import {
4
4
  ARTIFACT_LABEL_MAX_LENGTH,
5
5
  ARTIFACT_SCOPE_MAX_ARTIFACTS,
6
6
  ARTIFACT_TITLE_MAX_LENGTH,
7
+ PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
8
+ PLAYBOOK_ARGUMENT_MAX_COUNT,
9
+ PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH,
7
10
  PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
8
11
  RULE_TEXT_HARD_LIMIT_CHARACTERS,
9
12
  SKILL_INVOCATION_MAX_CALL_DEPTH,
@@ -522,12 +525,46 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
522
525
  * mechanically instantiated into other artifacts; a Playbook is never instantiated, it's read
523
526
  * and followed, and it never composes other Playbooks the way a Skill can call another Skill.
524
527
  */
528
+ export interface PlaybookArgument {
529
+ name: string;
530
+ description?: string;
531
+ /** Defaults true: naming an argument at all is a signal it matters, so an author must opt out explicitly to make one optional. */
532
+ required: boolean;
533
+ }
534
+
535
+ /** Rejects malformed input rather than silently dropping a bad entry -- the same posture creation validation already takes everywhere else. */
536
+ function validatePlaybookArguments(value: unknown): PlaybookArgument[] | undefined {
537
+ if (value === undefined) return undefined;
538
+ if (!Array.isArray(value)) throw new Error("playbook arguments must be an array");
539
+ if (value.length > PLAYBOOK_ARGUMENT_MAX_COUNT) throw new Error(`playbook arguments cannot exceed ${PLAYBOOK_ARGUMENT_MAX_COUNT} entries`);
540
+ const seen = new Set<string>();
541
+ return value.map((entry, index) => {
542
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`argument at index ${index} must be an object`);
543
+ const record = entry as Record<string, unknown>;
544
+ const name = record["name"];
545
+ if (typeof name !== "string" || name.trim().length === 0 || name.length > PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH) {
546
+ throw new Error(`argument name must be between 1 and ${PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH} characters`);
547
+ }
548
+ if (seen.has(name)) throw new Error(`argument name "${name}" is declared more than once`);
549
+ seen.add(name);
550
+ const description = record["description"];
551
+ if (description !== undefined && (typeof description !== "string" || description.length > PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH)) {
552
+ throw new Error(`argument "${name}" description cannot exceed ${PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH} characters`);
553
+ }
554
+ const required = record["required"];
555
+ if (required !== undefined && typeof required !== "boolean") throw new Error(`argument "${name}" required must be a boolean`);
556
+ return { name, ...(description !== undefined ? { description: description as string } : {}), required: required !== false };
557
+ });
558
+ }
559
+
525
560
  export interface CreatePlaybookInput {
526
561
  title: string;
527
562
  body?: string;
528
563
  trigger?: string;
529
564
  steps?: string[];
530
565
  tools?: string[];
566
+ /** Declares named arguments this Playbook needs -- see playbookInvocation for how a missing required one surfaces. */
567
+ arguments?: unknown;
531
568
  labels?: string[];
532
569
  extra?: Record<string, unknown>;
533
570
  projectRoot?: string;
@@ -538,6 +575,7 @@ export type UpdatePlaybookInput = UpdateContentInput;
538
575
 
539
576
  export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreatePlaybookInput, context?: ArtifactEventContext): Artifact {
540
577
  const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
578
+ const declaredArguments = validatePlaybookArguments(input.arguments);
541
579
  const playbook = artifacts.create({
542
580
  kind: "playbook",
543
581
  status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
@@ -549,6 +587,7 @@ export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeSt
549
587
  ...(input.trigger ? { trigger: input.trigger } : {}),
550
588
  ...(input.steps ? { steps: input.steps } : {}),
551
589
  ...(input.tools ? { tools: input.tools } : {}),
590
+ ...(declaredArguments ? { arguments: declaredArguments } : {}),
552
591
  },
553
592
  }, context);
554
593
  scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
@@ -587,16 +626,34 @@ export function transitionPlaybook(artifacts: ArtifactStore, id: string, action:
587
626
  return artifacts.setStatus(id, target, context)!;
588
627
  }
589
628
 
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 {
629
+ /**
630
+ * Renders trigger/steps/tools/arguments into readable guidance, plus any real linked artifacts.
631
+ * No nested playbook-calls-playbook composition -- a Playbook is a flat procedure, not a
632
+ * composable bundle. `provided` is the caller's already-known argument values (e.g. from the
633
+ * conversation so far); any declared *required* argument missing from it is called out
634
+ * explicitly, directing the agent to discuss (live:true) rather than guess or silently proceed.
635
+ */
636
+ export function playbookInvocation(artifacts: ArtifactStore, id: string, provided: Record<string, string> = {}): string {
592
637
  const playbook = requireKind(artifacts, id, "playbook");
593
638
  const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
594
639
  const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
595
640
  const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
641
+ const declaredArguments = Array.isArray(playbook.extra["arguments"]) ? (playbook.extra["arguments"] as PlaybookArgument[]) : [];
642
+ const argumentLines = declaredArguments.map((argument) => {
643
+ const value = provided[argument.name];
644
+ if (value !== undefined) return `- ${argument.name}: ${value}`;
645
+ const qualifier = argument.required ? "required" : "optional";
646
+ return `- ${argument.name} (${qualifier}${argument.description ? `: ${argument.description}` : ""}) -- not yet provided`;
647
+ });
648
+ const missingRequired = declaredArguments.filter((argument) => argument.required && provided[argument.name] === undefined);
596
649
  const sections = [[
597
650
  `Apply Papyrus playbook "${playbook.title}" (${playbook.id}).`,
598
651
  `Trigger: ${trigger}`,
599
652
  ...(playbook.body ? [`Context: ${playbook.body}`] : []),
653
+ ...(argumentLines.length > 0 ? ["Arguments:", ...argumentLines] : []),
654
+ ...(missingRequired.length > 0
655
+ ? [`Missing required argument(s): ${missingRequired.map((argument) => argument.name).join(", ")}. Ask the human for these directly -- the discuss tool with live:true asks synchronously and gets a real answer in this same turn -- before proceeding with the steps below. Do not guess or invent a value.`]
656
+ : []),
600
657
  ...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
601
658
  ...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
602
659
  ].join("\n")];
@@ -61,12 +61,13 @@ export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactSc
61
61
  define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, scopes, {
62
62
  title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
63
63
  steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
64
+ arguments: input["arguments"],
64
65
  labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
65
66
  projectRoot: optionalString(input, "project_root"),
66
67
  }, eventContext(input))),
67
68
  define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, scopes, artifactFilter(input))),
68
69
  define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
69
- define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"))),
70
+ define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"), input["arguments"] as Record<string, string> | undefined)),
70
71
  define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
71
72
  define("playbooks.disable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "disable", eventContext(input))),
72
73
  define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),