@danypops/pi-papyrus 0.44.0 → 0.45.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
@@ -10,13 +10,14 @@ The `papyrus_*` tools are the low-level graph-store API:
10
10
  - **`papyrus_graph`** — link artifacts, perform bounded traversal, or read the mutation event log
11
11
  - **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
12
12
 
13
- Agent-facing domain tools own lifecycle invariants and sit above this store API. `discuss` is still a single tool with an `action` parameter -- `live:true` needs an interactive UI round-trip a stateless Vehicle operation can't express. `notes`, `docs`, `rules`, `playbooks`, and `tasks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `playbooks_invoke`, `tasks_complete`, and so on) -- no `action` dispatch, each with its own schema:
13
+ Agent-facing domain tools own lifecycle invariants and sit above this store API. `notes`, `docs`, `rules`, `playbooks`, `tasks`, and `discuss` are all projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `playbooks_invoke`, `tasks_complete`, `discuss_open`, and so on) -- no `action` dispatch, each with its own schema. `discuss_open`/`discuss_reply` additionally accept `live: true` for a synchronous human round-trip, via vehicle-client-pi's `interactiveFollowUps` hook rather than a bespoke tool:
14
14
 
15
15
  - **tasks** (`tasks_create`, `tasks_update`, `tasks_list`, `tasks_show`, `tasks_plan`, `tasks_graph`, `tasks_focus`, `tasks_pause`, `tasks_unpause`, `tasks_clear_focus`, `tasks_start`, `tasks_submit`, `tasks_complete`, `tasks_reject`, `tasks_retry`, `tasks_cancel`, `tasks_cancel_subtree`, `tasks_run_gates`, `tasks_set_checklist`, `tasks_set_gates`, `tasks_depend`, `tasks_undepend`, `tasks_contain`, `tasks_uncontain`, `tasks_claim`, `tasks_heartbeat_lease`, `tasks_release_lease`, `tasks_lease`, `tasks_context`, `tasks_event_feed`, `tasks_scope`, `tasks_set_scope`, `tasks_assign_project`, `tasks_active`, `tasks_focused`, `tasks_history`) — manages the singleton active focus, evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort. `project_root` is required wherever a plain `tasks` call would otherwise need one (list/graph/plan/active/focused/scope/context/create) -- there is no ambient Pi cwd server-side. `tasks_focus`/`tasks_pause`/`tasks_unpause`/`tasks_clear_focus` still authorize their write via this session's own cached secret, and still broadcast `papyrus.task-focus.v1` on Pi's own event bus for a sibling extension to observe, exactly as before
16
16
  - **notes** (`notes_capture`, `notes_list`, `notes_show`, `notes_consume`, `notes_promote`, `notes_archive`) — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Playbook, or archive it with an explicit disposition
17
17
  - **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
18
18
  - **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
19
- - **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss` with `live:true` rather than guess
19
+ - **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss_open`/`discuss_reply` with `live:true` rather than guess
20
+ - **discuss** (`discuss_open`, `discuss_reply`, `discuss_defer`, `discuss_resume`, `discuss_settle`, `discuss_block`, `discuss_unblock`, `discuss_show`, `discuss_rounds`, `discuss_list`) — a Discussion persists across multiple rounds and can genuinely block a Task's completion until settled or deferred (`discuss_block`/`discuss_unblock`). `discuss_reply` is refused once deferred or settled -- `discuss_resume` first. `discuss_open`/`discuss_reply` can pose a structured choice via `options` (2-10 entries, each a bare string or `{title, description}`) + `options_mode` (`single`/`multi`); a later `discuss_reply` answers it via `selected`, validated against it. `live: true` on either gets the human's answer synchronously in the same tool call -- the operation itself always durably records the round first (exactly like every other Vehicle operation), then an optional local prompt (the pending choice's picker if one was posed, otherwise a freeform question) runs via a per-operation `interactiveFollowUps` resolver (see `@danypops/vehicle-client-pi`), degrading silently to the plain async round when there's no interactive UI
20
21
 
21
22
  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.
22
23
 
@@ -52,7 +53,7 @@ A persistent widget (matching the Task widget) shows a simple `Notes N` count, s
52
53
 
53
54
  ## Discuss
54
55
 
55
- Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `doc` artifact with `subtype: "discussion"` -- real graph citizenship (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle (`active`/`deferred`/`settled`) lives in `extra.discussion`, since Papyrus enforces status vocabulary per kind, not per subtype.
56
+ Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `task` artifact with `subtype: "discussion"` -- real graph citizenship (edges, show/list, `blocks`) without a dedicated enforced artifact kind of its own. Its fine-grained lifecycle (`active`/`deferred`/`settled`) lives in `extra.discussion`, since Papyrus enforces status vocabulary per kind, not per subtype.
56
57
 
57
58
  Rounds are a dedicated append-only child table (mirroring Task history's own shape): `open` records round 1, `reply` appends further rounds, refused once the Discussion is `deferred` or `settled` -- resume first. `defer` is explicitly non-blocking (paused, reason optional, resumable); `settle` is terminal, records an outcome, and archives the Doc. `block`/`unblock` manage the blocking relationship to a Task independently of `open`.
58
59
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * discuss.open/discuss.reply's live:true synchronous human round-trip -- the
3
+ * one piece of discuss's own retired pi.registerTool() the plain
4
+ * request/response Vehicle projection can't express on its own, wired in via
5
+ * vehicle-client-pi's interactiveFollowUps hook (vehicle-notes-client.ts).
6
+ *
7
+ * The primary invoke() always durably records the round first, exactly like
8
+ * every other Vehicle operation -- this only ever adds an OPTIONAL synchronous
9
+ * prompt on top when ctx.hasUI and the caller actually asked for one. No
10
+ * answer (canceled, no UI, live not requested) falls back to the operation's
11
+ * own default content/details, matching the retired tool's exact fallback
12
+ * behavior.
13
+ */
14
+ import type { DiscussionAndRounds } from "@danypops/papyrus";
15
+ import { readDiscussionExtra } from "@danypops/papyrus";
16
+ import type { PiVehicleInteractiveFollowUp } from "@danypops/vehicle-client-pi";
17
+ import { askQuestion } from "./discuss-ask-view.ts";
18
+
19
+ export const discussLiveFollowUp: PiVehicleInteractiveFollowUp = async (request, output, client) => {
20
+ const live = (request.input as { live?: unknown } | undefined)?.live === true;
21
+ if (!live || !request.context.hasUI) return undefined;
22
+
23
+ const result = output as DiscussionAndRounds;
24
+ const pending = (() => {
25
+ try {
26
+ return readDiscussionExtra(result.discussion.extra);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ })();
31
+ // The just-recorded round's own content IS the real question -- a generic "Reply to
32
+ // <title>:" wrapper as the primary question, with the real content demoted to
33
+ // "Context:", left a human staring at a labeled-backwards prompt (live-observed on the
34
+ // retired tool). The wrapper is now only a fallback for the degenerate case of empty
35
+ // content; the title becomes a plain orientation subtitle instead.
36
+ const question = result.rounds[0]?.content?.trim() || `Reply to "${result.discussion.title}":`;
37
+ const subtitle = result.discussion.title;
38
+ const answer = await askQuestion(request.context, {
39
+ question,
40
+ subtitle,
41
+ onUpdate: request.onUpdate,
42
+ signal: request.signal,
43
+ ...(pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
44
+ ? {
45
+ options: pending.pendingOptions.map((title, index) => ({
46
+ title,
47
+ description: pending.pendingOptionDescriptions?.[index] || undefined,
48
+ })),
49
+ allowMultiple: pending.pendingOptionsMode === "multi",
50
+ }
51
+ : {}),
52
+ });
53
+ if (!answer) return undefined; // canceled, or no UI capable of answering -- default content/details stand
54
+
55
+ const answered = (await client.invoke("discuss.reply", 1, {
56
+ id: result.discussion.id,
57
+ actor: "human",
58
+ content: answer.content,
59
+ ...(answer.selected ? { selected: answer.selected } : {}),
60
+ source: "discuss-live",
61
+ })) as DiscussionAndRounds;
62
+ return {
63
+ content: [{ type: "text", text: `"${answered.discussion.title}": ${answer.content}` }],
64
+ output: answered,
65
+ };
66
+ };
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * discuss.ts — /discuss interactive panel.
3
3
  * Reuses the generic artifact browser (artifact-browser.ts), same as docs.ts/rules.ts/notes.ts:
4
- * a Discussion is a `doc` artifact, so the browser's list/filter/refresh/select-action loop
5
- * applies unchanged. The one real wrinkle is that Discuss's meaningful lifecycle state
6
- * (active/deferred/settled) lives in extra.discussion, not the shared doc status column the
7
- * browser colors its row glyph by (see artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION
8
- * comment) -- so the real state is surfaced in rowMeta text instead, the same way rules.ts
9
- * surfaces severity and notes.ts surfaces history count, both also not the row glyph.
4
+ * a Discussion is a `task` artifact (subtype "discussion"), so the browser's
5
+ * list/filter/refresh/select-action loop applies unchanged. The one real wrinkle is that
6
+ * Discuss's meaningful lifecycle state (active/deferred/settled) lives in extra.discussion, not
7
+ * the shared status column the browser colors its row glyph by (see
8
+ * artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION comment) -- so the real state
9
+ * is surfaced in rowMeta text instead, the same way rules.ts surfaces severity and notes.ts
10
+ * surfaces history count, both also not the row glyph.
10
11
  *
11
- * Creating a new Discussion is left to the agent (the discuss tool), matching docs.ts/rules.ts/
12
- * playbooks.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
13
- * because Notes exists specifically as a human-authored inbox.
12
+ * Creating a new Discussion is left to the agent (discuss_open, Vehicle-projected -- see
13
+ * vehicle-notes-client.ts), matching docs.ts/rules.ts/playbooks.ts precedent -- Notes is the one
14
+ * kind with a human-facing creation command (/note), because Notes exists specifically as a
15
+ * human-authored inbox.
14
16
  */
15
17
 
16
18
  import { type Artifact, type DiscussionAndRounds, readDiscussionExtra } from "@danypops/papyrus";
@@ -82,7 +84,7 @@ function discussionActions(discussion: Artifact): string[] {
82
84
 
83
85
  export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
84
86
  await showArtifactBrowser(ctx, {
85
- kind: "doc",
87
+ kind: "discussion",
86
88
  title: "Discussions",
87
89
  listOperation: "discuss.list",
88
90
  statusOrder: ["draft", "active", "archived"],
@@ -1,98 +1,6 @@
1
- import { type Artifact, type DiscussionAndRounds, type DiscussionRound, type OperationName, readDiscussionExtra } from "@danypops/papyrus";
2
- import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Type } from "typebox";
4
- import { askQuestion } from "./discuss-ask-view.ts";
1
+ import type { Artifact, OperationName } from "@danypops/papyrus";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
3
  import { callService } from "./service-client.ts";
6
- import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
7
- import {
8
- createArtifactDetails,
9
- createArtifactListDetails,
10
- createModelContent,
11
- createPreviewDetails,
12
- } from "./tool-rendering/render-model.ts";
13
-
14
- function text(message: string, details: unknown = {}) {
15
- const modelContent = createModelContent(message);
16
- return { content: [{ type: "text" as const, text: modelContent.text }], details };
17
- }
18
-
19
- /**
20
- * live:true's synchronous half: reuses the same Discuss-owned ask UI (discuss-ask-view.ts) the
21
- * /discuss TUI's own "Reply" action uses when the just-created round posed a structured choice,
22
- * or a plain freeform prompt otherwise -- so "ask" covers both a completely open question and a
23
- * choice tied to this specific Discussion. Returns undefined on cancel or when no interactive UI
24
- * is available, never throws -- an unanswered live prompt still leaves the round it already
25
- * recorded intact.
26
- */
27
- /**
28
- * The discuss tool accepts each option as either a bare string (self-evident choices) or
29
- * {title, description} (a real tradeoff worth spelling out) -- normalizes to the two parallel
30
- * arrays discuss.open/discuss.reply actually expect (options: string[], option_descriptions:
31
- * string[], index-aligned, empty string meaning "none for this one"). Mutates params in place.
32
- */
33
- function normalizeDiscussOptions(params: Record<string, unknown>): void {
34
- const raw = params.options;
35
- if (!Array.isArray(raw)) return;
36
- let anyDescription = false;
37
- const titles: string[] = [];
38
- const descriptions: string[] = [];
39
- for (const entry of raw) {
40
- if (typeof entry === "string") {
41
- titles.push(entry);
42
- descriptions.push("");
43
- continue;
44
- }
45
- if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
46
- const record = entry as Record<string, unknown>;
47
- titles.push(record.title as string);
48
- const description = typeof record.description === "string" ? record.description : "";
49
- if (description) anyDescription = true;
50
- descriptions.push(description);
51
- continue;
52
- }
53
- titles.push(String(entry));
54
- descriptions.push("");
55
- }
56
- params.options = titles;
57
- if (anyDescription) params.option_descriptions = descriptions;
58
- }
59
-
60
- async function liveAnswer(
61
- ctx: ExtensionContext,
62
- discussion: Artifact,
63
- latestContent: string | undefined,
64
- onUpdate: AgentToolUpdateCallback | undefined,
65
- signal: AbortSignal | undefined,
66
- ): Promise<{ content: string; selected?: string[] } | undefined> {
67
- if (!ctx.hasUI) return undefined;
68
- const pending = (() => {
69
- try {
70
- return readDiscussionExtra(discussion.extra);
71
- } catch {
72
- return undefined;
73
- }
74
- })();
75
- // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
76
- // wrapper as the primary question, with the real content demoted to "Context:", left a human
77
- // staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
78
- // the degenerate case of empty content; the title becomes a plain orientation subtitle instead.
79
- const question = latestContent?.trim() || `Reply to "${discussion.title}":`;
80
- const subtitle = discussion.title;
81
- if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
82
- return askQuestion(ctx, {
83
- question,
84
- subtitle,
85
- options: pending.pendingOptions.map((title, index) => ({
86
- title,
87
- description: pending.pendingOptionDescriptions?.[index] || undefined,
88
- })),
89
- allowMultiple: pending.pendingOptionsMode === "multi",
90
- onUpdate,
91
- signal,
92
- });
93
- }
94
- return askQuestion(ctx, { question, subtitle, onUpdate, signal });
95
- }
96
4
 
97
5
  /**
98
6
  * Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
@@ -202,195 +110,8 @@ export async function resolveNameFields(
202
110
  }
203
111
  }
204
112
 
205
- /** Resolves a `namesKey` string array to an `idsKey` id array, only when idsKey isn't already explicitly given. */
206
- async function resolveNameArrayField(
207
- params: Record<string, unknown>,
208
- namesKey: string,
209
- idsKey: string,
210
- listOperation: OperationName,
211
- baseRequest: Record<string, unknown>,
212
- notes?: string[],
213
- ): Promise<void> {
214
- const names = params[namesKey];
215
- if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
216
- params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry), notes)));
217
- }
218
- }
219
-
220
- /**
221
- * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/playbooks) --
222
- * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
223
- * not owned by any one domain module, so every domain tool exposes the same two actions
224
- * over the same two operations rather than reinventing trash semantics four times.
225
- * Returns null when action is neither, so callers fall through to their own dispatch.
226
- */
227
- async function _handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
228
- // Trashed/restored artifacts stay directly showable, so known identities render by title on
229
- // either side of the action. An unresolved explicit id stays in structured/error channels;
230
- // normal model text does not turn that backend key into the artifact's public name.
231
- const titleOf = async (): Promise<string> => {
232
- try {
233
- const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params.id });
234
- return artifact ? `"${artifact.title}"` : "unknown artifact";
235
- } catch {
236
- return "unknown artifact";
237
- }
238
- };
239
- if (action === "remove") {
240
- const label = await titleOf();
241
- const record = await callService<
242
- Record<string, unknown>,
243
- { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }
244
- >("artifact.remove", params);
245
- const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
246
- return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
247
- }
248
- if (action === "restore") {
249
- const label = await titleOf();
250
- const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
251
- const output = outcome.restored ? `Restored ${label}.` : `${label} was not trashed.`;
252
- return text(output, createPreviewDetails("artifact.restore", "Restored", output));
253
- }
254
- if (action === "remove_subtree") {
255
- const label = await titleOf();
256
- const outcome = await callService<Record<string, unknown>, { removed: string[]; skipped: string[] }>("artifact.remove_subtree", params);
257
- const message = `Trashed ${label} and ${outcome.removed.length - 1} contained artifact(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-trashed` : ""}.`;
258
- return text(message, createPreviewDetails("artifact.remove_subtree", "Trashed subtree", JSON.stringify(outcome, null, 2)));
259
- }
260
- return null;
261
- }
262
-
263
- // notes.*, rules.*, docs.*, playbooks.*, tasks.*, and the shared artifact.* are
264
- // registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
265
- // src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
266
-
267
- export function registerDiscussTool(pi: ExtensionAPI): void {
268
- pi.registerTool({
269
- name: "discuss",
270
- label: "Discuss",
271
- description:
272
- "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. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. 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. The live picker docks in the input area itself (falls back to a plain text prompt if unsupported in the current UI mode). 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.",
273
- parameters: Type.Object({
274
- action: Type.String(),
275
- id: Type.Optional(Type.String()),
276
- name: Type.Optional(Type.String()),
277
- title: Type.Optional(Type.String()),
278
- actor: Type.Optional(Type.String()),
279
- content: Type.Optional(Type.String()),
280
- body: Type.Optional(Type.String()),
281
- labels: Type.Optional(Type.Array(Type.String())),
282
- blocks_task_ids: Type.Optional(Type.Array(Type.String())),
283
- blocks_task_names: Type.Optional(Type.Array(Type.String())),
284
- task_id: Type.Optional(Type.String()),
285
- task_name: Type.Optional(Type.String()),
286
- reason: Type.Optional(Type.String()),
287
- settlement: Type.Optional(Type.String()),
288
- state: Type.Optional(Type.String()),
289
- after_round: Type.Optional(Type.Number()),
290
- limit: Type.Optional(Type.Number()),
291
- options: Type.Optional(
292
- Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })])),
293
- ),
294
- options_mode: Type.Optional(Type.String()),
295
- selected: Type.Optional(Type.Array(Type.String())),
296
- live: Type.Optional(Type.Boolean()),
297
- }),
298
- // Blocks other tool calls in the same assistant turn until live:true's human answer comes
299
- // back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
300
- // bash/edit/write and let those run before the human sees the prompt.
301
- executionMode: "sequential",
302
- renderCall(args, theme) {
303
- return renderPapyrusToolCall("Discuss", args, theme);
304
- },
305
- renderResult(result, options, theme, context) {
306
- return renderPapyrusToolResult(result, options, theme, context);
307
- },
308
- async execute(_id, rawParams, signal, onUpdate, ctx) {
309
- try {
310
- const params: Record<string, unknown> = { ...rawParams };
311
- const action = params.action;
312
- const taskScope = { project_root: ctx.cwd };
313
- await resolveNameFields(params, [
314
- { nameKey: "name", idKey: "id", listOperation: "discuss.list", baseRequest: {} },
315
- { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
316
- ]);
317
- await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
318
- if (action === "open" || action === "reply") {
319
- normalizeDiscussOptions(params);
320
- // Matches the tasks/notes tools' own convention: an agent-driven mutation with no
321
- // explicit human actor still needs a real, non-generic label for the audit trail,
322
- // not a bare daemon rejection.
323
- if (typeof params.actor !== "string" || params.actor.length === 0) params.actor = "agent";
324
- const operation = action === "open" ? "discuss.open" : "discuss.reply";
325
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
326
- const fallback =
327
- action === "open"
328
- ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
329
- : text(
330
- `Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`,
331
- createArtifactDetails("discuss.reply", result.discussion),
332
- );
333
- if (params.live !== true) return fallback;
334
- const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
335
- if (!answer) return fallback;
336
- const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
337
- id: result.discussion.id,
338
- actor: "human",
339
- content: answer.content,
340
- ...(answer.selected ? { selected: answer.selected } : {}),
341
- source: "discuss-live",
342
- });
343
- return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
344
- }
345
- if (action === "block" || action === "unblock") {
346
- const operation = action === "block" ? "discuss.block" : "discuss.unblock";
347
- const [outcome, discussionAndRounds, task] = await Promise.all([
348
- callService<Record<string, unknown>, { blocked?: boolean; unblocked?: boolean }>(operation, params),
349
- callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: params.id }),
350
- callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
351
- ]);
352
- const discussion = discussionAndRounds.discussion;
353
- const message =
354
- action === "unblock" && !outcome.unblocked
355
- ? "No such blocking relationship."
356
- : `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
357
- return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
358
- }
359
- if (action === "show") {
360
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
361
- const rounds = result.rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n");
362
- return text(`${artifactLine(result.discussion)}\n\n${rounds}`, createArtifactDetails("discuss.show", result.discussion));
363
- }
364
- if (action === "rounds") {
365
- const rounds = await callService<Record<string, unknown>, DiscussionRound[]>("discuss.rounds", params);
366
- const output = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || "No rounds.";
367
- return text(output, createPreviewDetails("discuss.rounds", "Discussion rounds", output));
368
- }
369
- if (action === "list") {
370
- const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
371
- return text(
372
- rows.length ? artifactLines(rows).join("\n") : "No discussions found.",
373
- createArtifactListDetails("discuss.list", rows),
374
- );
375
- }
376
- const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
377
- const operation = operations[action as keyof typeof operations];
378
- if (!operation) throw new Error(`unknown discuss action: ${action}`);
379
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
380
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
381
- } catch (error) {
382
- throw new Error(`discuss failed: ${error instanceof Error ? error.message : error}`);
383
- }
384
- },
385
- });
386
- }
387
-
388
- /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
389
- // notes, rules, docs, playbooks, and tasks are no longer registered here -- all migrated
390
- // onto Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
391
- // index.ts), replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
392
- // src/vehicle/papyrus-vehicle.ts for the server side. discuss remains here -- live:true needs an
393
- // interactive UI round-trip a stateless Vehicle operation can't express.
394
- export function registerDomainTools(pi: ExtensionAPI): void {
395
- registerDiscussTool(pi);
396
- }
113
+ // notes.*, rules.*, docs.*, playbooks.*, tasks.*, discuss.*, and the shared artifact.*
114
+ // are all registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
115
+ // src/vehicle/papyrus-vehicle.ts) -- no domain has a hand-rolled pi.registerTool() left
116
+ // in this file. What remains here (above) is name-resolution machinery still used
117
+ // directly by index.ts's own low-level tools (papyrus_graph).
@@ -37,7 +37,7 @@ import { buildTaskItemTree, computeContextBudget } from "./context-budget.ts";
37
37
  import { PAPYRUS_CONTEXT_HUB_PRODUCER_NAME, papyrusContextSegment } from "./context-hub-contribution.ts";
38
38
  import { buildContextInjection } from "./context-injection-telemetry.ts";
39
39
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
40
- import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
40
+ import { resolveNameFields } from "./domain-tools.ts";
41
41
  import { renderNoteWidgetLines } from "./note-widget.ts";
42
42
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
43
43
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
@@ -351,7 +351,6 @@ export class NoteOverlay {
351
351
 
352
352
  export default async function (pi: ExtensionAPI) {
353
353
  setTaskFocusEventBus(pi);
354
- registerDomainTools(pi);
355
354
  registerPlaybookBridge(pi);
356
355
  let contextInjectionSequence = 0;
357
356
  const contextInjectionProducerId = randomUUID();
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, playbooks.*,
3
- * tasks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
4
- * src/vehicle/papyrus-vehicle.ts.
3
+ * tasks.*, discuss.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
4
+ * src/vehicle/papyrus-vehicle.ts. discuss.* is the last of the six domains to
5
+ * migrate off pi-papyrus's own retired hand-rolled pi.registerTool() mega-tool.
5
6
  *
6
7
  * Fails silently on a stale/unreachable daemon handle instead of aborting extension
7
8
  * setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
@@ -23,6 +24,7 @@ import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon
23
24
  import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
24
25
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
25
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { discussLiveFollowUp } from "./discuss-live-follow-up.ts";
26
28
  import { currentVehicleClientTarget } from "./service-client.ts";
27
29
  import { sessionSecretField } from "./session-identity.ts";
28
30
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
@@ -39,6 +41,8 @@ const REGISTERED_PERMISSIONS = [
39
41
  "playbooks:write",
40
42
  "tasks:read",
41
43
  "tasks:write",
44
+ "discuss:read",
45
+ "discuss:write",
42
46
  "artifact:read",
43
47
  "artifact:write",
44
48
  ];
@@ -92,6 +96,15 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
92
96
  // token-cost router correlating its own telemetry with the currently focused task)
93
97
  // -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
94
98
  // than from the operation's own output.
99
+ // discuss.open/discuss.reply's own live:true synchronous human round-trip --
100
+ // see discuss-live-follow-up.ts. Every other operation's resolver call
101
+ // returns undefined, meaning zero behavior change for the other 5 domains.
102
+ interactiveFollowUps: (descriptor) =>
103
+ descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
104
+ // The retired discuss tool declared executionMode: "sequential" so the model
105
+ // couldn't batch a live ask alongside other tool calls in the same turn and
106
+ // let those run before the human sees the prompt -- same reasoning here.
107
+ executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
95
108
  onInvoked: ({ descriptor }, output) => {
96
109
  if (descriptor.name === "tasks.focus") {
97
110
  const artifact = output as { id: string } | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/jittor": "^0.14.0",
21
- "@danypops/papyrus": "^0.43.0",
21
+ "@danypops/papyrus": "^0.44.0",
22
22
  "@danypops/vehicle-client": "^0.5.0",
23
- "@danypops/vehicle-client-pi": "^0.8.1",
23
+ "@danypops/vehicle-client-pi": "^0.13.0",
24
24
  "@danypops/vehicle-core": "^0.10.0",
25
25
  "@danypops/vehicle-server": "^0.11.0",
26
26
  "beautiful-mermaid": "1.1.3",