@danypops/pi-papyrus 0.40.0 → 0.42.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,14 +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:
14
-
15
- - **`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
16
- - **`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
17
- - **`docs`**create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
18
- - **`rules`**create/update/list/show/preview, enable/disable, and attach governance gates to tasks
19
- - **`skills`** create/update/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
20
- - **`playbooks`** — a completely different beast from Skills at the authoring level (a trigger and an ordered list of steps, written as prose), but `invoke` recycles the same materialization engine workflow Skills use: it compiles the steps and any `contain`/`depend` composition into real Tasks (one per step, plus a container task per playbook in the tree), wires them with `dependsOn` so completing one auto-focuses the next, and focuses the first one. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `contain`/`uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `depend`/`undepend` chain a prerequisite Playbook before another (it must fully complete first). `preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?}`, required defaults true; referenced in step text 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
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`, `skills`, `playbooks`, and `tasks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, `tasks_complete`, and so on) -- no `action` dispatch, each with its own schema:
14
+
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
+ - **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/Skill, or archive it with an explicit disposition
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
+ - **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
+ - **skills** (`skills_create`, `skills_create_template`, `skills_list`, `skills_show`, `skills_invoke`, `skills_run`, `skills_enable`, `skills_disable`, `skills_instantiate`, `skills_assign_project`, `skills_update`) — `skills_run` atomically instantiates a parameterized workflow run; `skills_instantiate` instantiates a compatibility artifact-template
20
+ - **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 completely different beast from Skills at the authoring level (a trigger and an ordered list of steps, written as prose), but `playbooks_invoke` recycles the same materialization engine workflow Skills use: it compiles the steps and any `contain`/`depend` composition into real Tasks (one per step, plus a container task per playbook in the tree), wires them with `dependsOn` so completing one auto-focuses the next, and focuses the first one. 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). `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?}`, required defaults true; referenced in step text 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
21
21
 
22
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.
23
23
 
@@ -1,34 +1,18 @@
1
1
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import {
4
- PROOF_TYPES,
5
4
  readDiscussionExtra,
6
5
  type Artifact,
7
6
  type DiscussionAndRounds,
8
7
  type DiscussionRound,
9
- type GateResult,
10
8
  type OperationName,
11
- type PlaybookInvocationResult,
12
- type PlaybookMissingArguments,
13
- type WorkflowRunResult,
14
- type TaskCompletion,
15
- type TaskExecutionPlan,
16
- type TaskGraph,
17
- type TaskHistoryPage,
18
- type TaskLease,
19
- type TaskViewSelection,
20
9
  } from "@danypops/papyrus";
21
10
  import { askQuestion } from "./discuss-ask-view.ts";
22
- import { emitTaskFocusEvent } from "./task-focus-events.ts";
23
- import { sessionSecretField } from "./session-identity.ts";
24
11
  import { callService } from "./service-client.ts";
25
12
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
26
13
  import {
27
14
  createArtifactDetails,
28
15
  createArtifactListDetails,
29
- createGateRunDetails,
30
- createGraphDetails,
31
- createInvocationDetails,
32
16
  createModelContent,
33
17
  createPreviewDetails,
34
18
  } from "./tool-rendering/render-model.ts";
@@ -115,15 +99,6 @@ export function artifactLines(artifacts: Artifact[]): string[] {
115
99
  return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
116
100
  }
117
101
 
118
- /** Resolves internal ids for model text; ids resurface only when equal titles need disambiguation. */
119
- async function artifactLabelsById(ids: readonly string[]): Promise<Map<string, string>> {
120
- const uniqueIds = [...new Set(ids)];
121
- const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
122
- const titleCounts = new Map<string, number>();
123
- for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
124
- return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
125
- }
126
-
127
102
  /**
128
103
  * Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
129
104
  * a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
@@ -185,23 +160,6 @@ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest
185
160
  }
186
161
  }
187
162
 
188
- /**
189
- * Playbook `arguments` is intentionally untyped in this tool's schema (an array on create, a
190
- * {name: value} map on invoke) -- unlike every other JSON-shaped field here, which has a concrete
191
- * array/record schema the calling layer can serialize correctly. A genuinely schema-less field can
192
- * arrive pre-serialized as JSON text instead of a parsed value; parse it back in place before it
193
- * reaches the service, the same tolerance the CLI's own --arguments-json/--*-json flags already give.
194
- */
195
- export function normalizeJsonEncodedField(params: Record<string, unknown>, key: string): void {
196
- const value = params[key];
197
- if (typeof value !== "string") return;
198
- try {
199
- params[key] = JSON.parse(value);
200
- } catch {
201
- throw new Error(`${key} must be valid JSON`);
202
- }
203
- }
204
-
205
163
  /**
206
164
  * Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in
207
165
  * place. `notes`, when given, collects a message for each name that only resolved by widening
@@ -276,464 +234,9 @@ async function handleArtifactRemoveRestore(action: unknown, params: Record<strin
276
234
  return null;
277
235
  }
278
236
 
279
- const proofReferenceSchema = Type.Object({
280
- type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
281
- target: Type.String(),
282
- expect: Type.Optional(Type.String()),
283
- });
284
-
285
- const checklistCriterionSchema = Type.Object({
286
- proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
287
- });
288
-
289
- export function registerTasksTool(pi: ExtensionAPI): void {
290
- pi.registerTool({
291
- name: "tasks",
292
- label: "Tasks",
293
- description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `parent_name`/`child_name`/`root_task_name` are the same pattern for their `_id` counterparts. For a prerequisite, use `dependency_name` (singular, resolved to `dependency_id`) with the `depend`/`undepend` actions; `depends_on_names` (plural array, resolved to `depends_on`) is only for `create`'s initial dependency set -- passing the wrong one of the two to `depend` leaves `dependency_id` unset and fails with a `dependency_id is required` error. A name resolved outside this call's own project scope (e.g. depending on a task in a different project) is retried once against every project before failing, and the response notes when that happened.",
294
- parameters: Type.Object({
295
- action: Type.String(),
296
- id: Type.Optional(Type.String()),
297
- name: Type.Optional(Type.String()),
298
- title: Type.Optional(Type.String()),
299
- body: Type.Optional(Type.String()),
300
- status: Type.Optional(Type.String()),
301
- text: Type.Optional(Type.String()),
302
- limit: Type.Optional(Type.Number()),
303
- cursor: Type.Optional(Type.Number()),
304
- direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
305
- reason: Type.Optional(Type.String()),
306
- session_id: Type.Optional(Type.String()),
307
- labels: Type.Optional(Type.Array(Type.String())),
308
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
309
- gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
310
- checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
311
- template_id: Type.Optional(Type.String()),
312
- parent_id: Type.Optional(Type.String()),
313
- parent_name: Type.Optional(Type.String()),
314
- child_id: Type.Optional(Type.String()),
315
- child_name: Type.Optional(Type.String()),
316
- dependency_id: Type.Optional(Type.String()),
317
- dependency_name: Type.Optional(Type.String()),
318
- depends_on: Type.Optional(Type.Array(Type.String())),
319
- depends_on_names: Type.Optional(Type.Array(Type.String())),
320
- project_root: Type.Optional(Type.String()),
321
- scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
322
- root_task_id: Type.Optional(Type.String()),
323
- root_task_name: Type.Optional(Type.String()),
324
- owner: Type.Optional(Type.String()),
325
- token: Type.Optional(Type.String()),
326
- ttl_ms: Type.Optional(Type.Number()),
327
- note: Type.Optional(Type.String()),
328
- event_types: Type.Optional(Type.Array(Type.String())),
329
- }),
330
- renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
331
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
332
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
333
- try {
334
- const params: Record<string, unknown> = { ...rawParams };
335
- const action = params.action;
336
- // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
337
- // without depending on the model to know or supply its own session identity.
338
- // session_secret is looked up by the resolved session_id itself (not blindly the
339
- // current session's), so a model that explicitly overrides session_id to a DIFFERENT
340
- // session never gets this session's secret smuggled in on its behalf -- the cache only
341
- // ever holds this extension's own registered session anyway (see session-identity.ts).
342
- const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
343
- const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
344
- // Collects a note whenever a name field below only resolved by widening past this call's
345
- // own project scope (see resolveArtifactIdByName) -- surfaced at the end of this action's
346
- // own response text rather than resolved silently, since a cross-project depend/contain
347
- // is exactly the case a shared per-call scope can't otherwise express.
348
- const notes: string[] = [];
349
- // Resolve the graph root first: every other name lookup must use the caller's final
350
- // project/scope/root selection, otherwise `scope: all|graph` silently collapses back
351
- // to the current project and forces callers to reach for an id.
352
- await resolveNameFields(params, [
353
- { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
354
- ], notes);
355
- const resolutionRequest = {
356
- ...baseRequest,
357
- ...(params.scope === undefined ? {} : { scope: params.scope }),
358
- ...(params.root_task_id === undefined ? {} : { root_task_id: params.root_task_id }),
359
- };
360
- // The daemon remains keyed by stable ids; the agent facade resolves names against the
361
- // exact requested view before dispatching those internal ids.
362
- await resolveNameFields(params, [
363
- { nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest: resolutionRequest },
364
- { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
365
- { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
366
- { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
367
- ], notes);
368
- await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
369
- const request = { ...params, ...baseRequest };
370
- const result = await (async (): Promise<ReturnType<typeof text>> => {
371
- if (action === "create") {
372
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
373
- return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
374
- }
375
- if (action === "list") {
376
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
377
- return text(rows.length ? artifactLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
378
- }
379
- if (action === "show") {
380
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
381
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
382
- }
383
- if (action === "history") {
384
- const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
385
- const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
386
- const output = lines.join("\n") || "No recorded history for this task.";
387
- return text(output, createPreviewDetails("tasks.history", "Task history", output));
388
- }
389
- if (action === "scope") {
390
- const selection = await callService<Record<string, unknown>, TaskViewSelection>("tasks.scope", request);
391
- return text(`Task scope: ${selection.label}`, createPreviewDetails("tasks.scope", "Task scope", selection.label));
392
- }
393
- if (action === "active") {
394
- const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
395
- return artifact
396
- ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
397
- : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
398
- }
399
- if (action === "focused") {
400
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
401
- return focus
402
- ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
403
- : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
404
- }
405
- if (action === "pause" || action === "unpause") {
406
- const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
407
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
408
- emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
409
- return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
410
- }
411
- if (action === "clear_focus") {
412
- const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
413
- if (result.cleared) emitTaskFocusEvent({ taskId: null, sessionId: request.session_id as string, status: "cleared" });
414
- const output = result.cleared ? "Task focus cleared." : "No focused task.";
415
- return text(output, createPreviewDetails("tasks.clear_focus", "Task focus", output));
416
- }
417
- if (action === "graph") {
418
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
419
- const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
420
- const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
421
- const edges = graph.nodes.flatMap((node) => [
422
- ...node.dependencyIds.map((dependencyId) => ({ from: node.task.id, relation: "depends_on", to: dependencyId })),
423
- ...node.childIds.map((childId) => ({ from: node.task.id, relation: "contains", to: childId })),
424
- ]);
425
- return text(
426
- `Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`,
427
- createGraphDetails("tasks.graph", graph.nodes.map((node) => node.task), edges),
428
- );
429
- }
430
- if (action === "plan") {
431
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
432
- const byId = new Map(plan.nodes.map((node) => [node.id, node]));
433
- const titleCounts = new Map<string, number>();
434
- for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
435
- const nodeLabel = (id: string): string => {
436
- const node = byId.get(id);
437
- if (!node) return "unknown task";
438
- return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
439
- };
440
- const lines = plan.layers.flatMap((layer, index) => [
441
- `Layer ${index + 1}`,
442
- ...layer.map((id) => {
443
- const node = byId.get(id);
444
- return ` [${node?.state ?? "unknown"}] ${nodeLabel(id)}`;
445
- }),
446
- ]);
447
- if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
448
- const output = lines.join("\n") || "No tasks in execution plan.";
449
- return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
450
- }
451
- if (action === "context") {
452
- const summary = await callService<Record<string, unknown>, string | null>("tasks.context", { ...request, verbosity: "full" });
453
- const output = summary ?? "No open tasks.";
454
- return text(output, createPreviewDetails("tasks.context", "Task reconciliation context", output));
455
- }
456
- if (action === "set_checklist") {
457
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
458
- return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
459
- }
460
- if (action === "set_gates") {
461
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_gates", params);
462
- return text(`Updated gates: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_gates", artifact));
463
- }
464
- if (action === "complete") {
465
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
466
- const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
467
- const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
468
- const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
469
- const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
470
- const dependencyLabels = await artifactLabelsById(result.blocked.flatMap((entry) => entry.dependencyIds));
471
- const blocked = result.blocked.length > 0
472
- ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.map((id) => dependencyLabels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
473
- : "";
474
- const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
475
- return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
476
- }
477
- if (action === "run_gates") {
478
- const [gates, task] = await Promise.all([
479
- callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request),
480
- callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.id }),
481
- ]);
482
- return text(
483
- gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
484
- createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", task.title, gates.map((gate) => ({
485
- passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
486
- }))),
487
- );
488
- }
489
- if (action === "event_feed") {
490
- const page = await callService<Record<string, unknown>, { events: Array<{ id: number; occurredAt: string; taskId: string; type: string }>; nextCursor?: number }>("tasks.event_feed", { cursor: params.cursor, limit: params.limit, event_types: params.event_types });
491
- const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
492
- return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
493
- }
494
- if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
495
- const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
496
- if (action === "release_lease") {
497
- const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
498
- const output = released.released ? "Lease released." : "No live lease to release.";
499
- return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
500
- }
501
- const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
502
- const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
503
- const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
504
- return text(output, createPreviewDetails(operation, "Task lease", output));
505
- }
506
- if (action === "cancel_subtree") {
507
- const outcome = await callService<Record<string, unknown>, { canceled: string[]; skipped: string[] }>("tasks.cancel_subtree", request);
508
- const output = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
509
- return text(output, createPreviewDetails("tasks.cancel_subtree", "Cancel task subtree", JSON.stringify(outcome, null, 2)));
510
- }
511
- const trashResult = await handleArtifactRemoveRestore(action, params);
512
- if (trashResult) return trashResult;
513
- const operations = {
514
- focus: "tasks.focus",
515
- start: "tasks.start",
516
- submit: "tasks.submit",
517
- reject: "tasks.reject",
518
- retry: "tasks.retry",
519
- cancel: "tasks.cancel",
520
- update: "tasks.update",
521
- set_scope: "tasks.set_scope",
522
- assign_project: "tasks.assign_project",
523
- depend: "tasks.depend",
524
- undepend: "tasks.undepend",
525
- contain: "tasks.contain",
526
- uncontain: "tasks.uncontain",
527
- } as const;
528
- const operation = operations[action as keyof typeof operations];
529
- if (!operation) throw new Error(`unknown tasks action: ${action}`);
530
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
531
- if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
532
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
533
- })();
534
- if (notes.length > 0 && result.content[0]?.type === "text") result.content[0].text += `\n\n${notes.join("\n")}`;
535
- return result;
536
- } catch (error) {
537
- throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
538
- }
539
- },
540
- });
541
- }
542
-
543
- // notes.*, rules.*, docs.*, and the shared artifact.* are registered as Vehicles
544
- // (see ../vehicle-notes-client.ts and @danypops/papyrus's src/vehicle/papyrus-vehicle.ts),
545
- // not pi.registerTool()s in this file.
546
-
547
- export function registerPlaybooksTool(pi: ExtensionAPI): void {
548
- pi.registerTool({
549
- name: "playbooks",
550
- label: "Playbooks",
551
- description: "Playbook domain tool -- a completely different beast from the skills tool at the AUTHORING level (a Playbook is prose: a trigger and an ordered list of steps), but invoke recycles the exact same materialization engine workflow Skills use: it compiles the Playbook's steps and its contains/depends_on composition tree into real Tasks (one per step, plus one container task per playbook in the tree), wires them with dependsOn so completing one auto-focuses the next, and focuses the first one -- no text dump, one step (page) surfaces at a time as it becomes the focused task, exactly like any other Task. contain/uncontain nest a child Playbook inside a parent (its steps run AFTER the parent's own, as part of it); depend/undepend chain a prerequisite Playbook before another (it must fully complete FIRST). Both are bounded; a composition cycle is a hard invoke-time error (real Tasks would otherwise be created in a loop), unlike preview's degrade-to-a-marker. ACTIONS: create, list, show, invoke, preview, enable, disable, assign_project, update, contain, uncontain, depend, undepend, remove, remove_subtree, restore. project_root is optional everywhere (omitted = unscoped). On create, `arguments` declares named inputs the Playbook needs: [{name, description?, required?}] (required defaults true) -- referenced in step text as `{{name}}`, substituted at invoke time. On invoke, `arguments` supplies known values as {name: value}; if any declared REQUIRED argument is still missing, invoke creates nothing and returns `missingArguments` -- ask the human for these (discuss tool, live:true) and invoke again, never guess or invent a value. A successful invoke returns `entryTaskId` (now focused) and `created.tasks` -- drive it forward with the tasks tool (start/submit/complete) like any other Task; contains/depends_on wiring auto-focuses each next step on completion. preview renders the whole tree as text with no side effects, for a human who just wants to read it first. 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; remove_subtree extends this to the whole nested-Playbook tree in one call. PREFER `name` (the playbook's exact title) over `id`, and `parent_name`/`child_name`/`dependency_name` over `parent_id`/`child_id`/`dependency_id` for contain/uncontain/depend/undepend -- all are backend implementation details, resolved from name automatically.",
552
- parameters: Type.Object({
553
- action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
554
- body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
555
- tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
556
- arguments: Type.Optional(Type.Unknown()),
557
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
558
- text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
559
- parent_id: Type.Optional(Type.String()), parent_name: Type.Optional(Type.String()),
560
- child_id: Type.Optional(Type.String()), child_name: Type.Optional(Type.String()),
561
- dependency_id: Type.Optional(Type.String()), dependency_name: Type.Optional(Type.String()),
562
- project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
563
- }),
564
- renderCall(args, theme) { return renderPapyrusToolCall("Playbooks", args, theme); },
565
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
566
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
567
- try {
568
- const params: Record<string, unknown> = { ...rawParams };
569
- const action = params.action;
570
- // Name resolution must search regardless of project scope -- a Playbook itself is
571
- // commonly unscoped (e.g. a cross-repo lab-deploy playbook), so resolutionRequest
572
- // uses the caller's ORIGINAL project_root (undefined unless explicitly given), never
573
- // the invoke-specific default applied below -- that default is only for where the
574
- // resulting TASKS land, not for finding the playbook artifact itself.
575
- const resolutionRequest = { project_root: params.project_root };
576
- await resolveNameFields(params, [
577
- { nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
578
- { nameKey: "parent_name", idKey: "parent_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
579
- { nameKey: "child_name", idKey: "child_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
580
- { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "playbooks.list", baseRequest: resolutionRequest },
581
- ]);
582
- // invoke ends by calling tasks.focus server-side -- that focus write must land in the
583
- // SAME session scope the tasks tool reads from (ctx.sessionManager.getSessionId()),
584
- // the same resolution the tasks tool itself always applies, or the entry task's focus
585
- // is invisible to tasks(action=focused/active) despite invoke reporting it as focused.
586
- // project_root defaults to ctx.cwd for the same reason: the tasks tool always scopes
587
- // its OWN reads to ctx.cwd unless told otherwise, so an unscoped playbook-materialized
588
- // task is invisible to tasks(action=focused) even with the right session -- confirmed
589
- // live (the focus_set event existed with the correct sessionId, but Tasks.focused's own
590
- // project-scope filter silently excluded the unscoped task from a cwd-scoped read).
591
- // Applied AFTER name resolution: it must never affect finding the playbook itself.
592
- if (action === "invoke") {
593
- const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
594
- Object.assign(params, {
595
- project_root: params.project_root ?? ctx.cwd,
596
- session_id: resolvedSessionId,
597
- ...sessionSecretField(resolvedSessionId as string),
598
- });
599
- }
600
- if (action === "create" || action === "invoke" || action === "preview") normalizeJsonEncodedField(params, "arguments");
601
- if (action === "create") {
602
- const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
603
- return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
604
- }
605
- if (action === "list") {
606
- const rows = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", params);
607
- return text(rows.length ? artifactLines(rows).join("\n") : "No playbooks found.", createArtifactListDetails("playbooks.list", rows));
608
- }
609
- if (action === "preview") {
610
- const rendered = await callService<Record<string, unknown>, string>("playbooks.preview", params);
611
- return text(rendered, createPreviewDetails("playbooks.preview", "Playbook preview", rendered));
612
- }
613
- if (action === "invoke") {
614
- const invocation = await callService<Record<string, unknown>, PlaybookInvocationResult | PlaybookMissingArguments>("playbooks.invoke", params);
615
- if ("missingArguments" in invocation) {
616
- const message = `Missing required argument(s): ${invocation.missingArguments.join(", ")}. Nothing was created -- ask the human for these (discuss tool, live:true), then invoke again.`;
617
- return text(message, createInvocationDetails("playbooks.invoke", invocation.playbookId, { tasks: [], docs: [], rules: [], roots: [] }));
618
- }
619
- const nodeTitleCounts = new Map<string, number>();
620
- for (const node of invocation.execution.nodes) nodeTitleCounts.set(node.title, (nodeTitleCounts.get(node.title) ?? 0) + 1);
621
- const execution = invocation.execution.nodes.map((node) => (nodeTitleCounts.get(node.title) ?? 0) > 1
622
- ? ` [${node.state}] ${node.title} (${node.id})`
623
- : ` [${node.state}] ${node.title}`).join("\n");
624
- const nodeById = new Map(invocation.execution.nodes.map((node) => [node.id, node]));
625
- const rootLabels = invocation.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
626
- const entryLabel = nodeById.get(invocation.entryTaskId)?.title ?? invocation.entryTaskId;
627
- const createdLabels = await artifactLabelsById([...invocation.created.docs, ...invocation.created.rules]);
628
- return text([
629
- `Invoked playbook run ${invocation.runId}: ${invocation.created.tasks.length} task(s), ${invocation.created.rules.length} rule(s), ${invocation.created.docs.length} doc(s) created.`,
630
- `Entry task now focused: ${entryLabel}. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`,
631
- `Ready roots: ${rootLabels.join(", ") || "none"}.`,
632
- `Context docs: ${invocation.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
633
- `Scoped rules: ${invocation.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
634
- ...(execution ? ["Execution:", execution] : []),
635
- ].join("\n"), createInvocationDetails("playbooks.invoke", invocation.runId, {
636
- tasks: invocation.created.tasks,
637
- docs: invocation.created.docs,
638
- rules: invocation.created.rules,
639
- roots: invocation.rootTaskIds,
640
- }));
641
- }
642
- const trashResult = await handleArtifactRemoveRestore(action, params);
643
- if (trashResult) return trashResult;
644
- const operations = {
645
- show: "playbooks.show", enable: "playbooks.enable", disable: "playbooks.disable", assign_project: "playbooks.assign_project", update: "playbooks.update",
646
- contain: "playbooks.contain", uncontain: "playbooks.uncontain", depend: "playbooks.depend", undepend: "playbooks.undepend",
647
- } as const;
648
- const operation = operations[action as keyof typeof operations];
649
- if (!operation) throw new Error(`unknown playbooks action: ${action}`);
650
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
651
- return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
652
- } catch (error) {
653
- throw new Error(`playbooks failed: ${error instanceof Error ? error.message : error}`);
654
- }
655
- },
656
- });
657
- }
658
-
659
- export function registerSkillsTool(pi: ExtensionAPI): void {
660
- pi.registerTool({
661
- name: "skills",
662
- label: "Skills",
663
- 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, remove_subtree, 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; remove_subtree extends this to a whole `contains` subtree in one call. 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.",
664
- parameters: Type.Object({
665
- action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
666
- body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
667
- tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
668
- arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
669
- labels: Type.Optional(Type.Array(Type.String())),
670
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
671
- text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
672
- template_name: Type.Optional(Type.String()),
673
- target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
674
- required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
675
- project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
676
- }),
677
- renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
678
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
679
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
680
- try {
681
- const params: Record<string, unknown> = { ...rawParams };
682
- const action = params.action;
683
- const request = { ...params, project_root: params.project_root ?? ctx.cwd };
684
- await resolveNameFields(params, [
685
- { nameKey: "name", idKey: "id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
686
- { nameKey: "template_name", idKey: "template_id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
687
- ]);
688
- if (action === "create" || action === "create_template") {
689
- const operation = action === "create" ? "skills.create" : "skills.create_template";
690
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
691
- return text(`Created skill ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
692
- }
693
- if (action === "list") {
694
- const rows = await callService<Record<string, unknown>, Artifact[]>("skills.list", params);
695
- return text(rows.length ? artifactLines(rows).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
696
- }
697
- if (action === "invoke") {
698
- const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
699
- return text(invocation, createPreviewDetails("skills.invoke", "Skill invocation", invocation));
700
- }
701
- if (action === "run") {
702
- const run = await callService<Record<string, unknown>, WorkflowRunResult>("skills.run", request);
703
- const runTitleCounts = new Map<string, number>();
704
- for (const node of run.execution.nodes) runTitleCounts.set(node.title, (runTitleCounts.get(node.title) ?? 0) + 1);
705
- const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
706
- ? ` [${node.state}] ${node.title} (${node.id})`
707
- : ` [${node.state}] ${node.title}`).join("\n");
708
- const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
709
- const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
710
- const createdLabels = await artifactLabelsById([...run.created.docs, ...run.created.rules]);
711
- return text([
712
- `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
713
- `Ready roots: ${rootLabels.join(", ") || "none"}.`,
714
- `Context docs: ${run.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
715
- `Scoped rules: ${run.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
716
- ...(execution ? ["Execution:", execution] : []),
717
- ].join("\n"), createInvocationDetails("skills.run", run.runId, {
718
- tasks: run.created.tasks,
719
- docs: run.created.docs,
720
- rules: run.created.rules,
721
- roots: run.rootTaskIds,
722
- }));
723
- }
724
- const trashResult = await handleArtifactRemoveRestore(action, params);
725
- if (trashResult) return trashResult;
726
- const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
727
- const operation = operations[action as keyof typeof operations];
728
- if (!operation) throw new Error(`unknown skills action: ${action}`);
729
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
730
- return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
731
- } catch (error) {
732
- throw new Error(`skills failed: ${error instanceof Error ? error.message : error}`);
733
- }
734
- },
735
- });
736
- }
237
+ // notes.*, rules.*, docs.*, skills.*, playbooks.*, tasks.*, and the shared artifact.* are
238
+ // registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
239
+ // src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
737
240
 
738
241
  export function registerDiscussTool(pi: ExtensionAPI): void {
739
242
  pi.registerTool({
@@ -838,13 +341,11 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
838
341
  }
839
342
 
840
343
  /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
841
- // docs and rules are no longer registered here -- both migrated onto Vehicle
842
- // (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
843
- // index.ts), replacing their own pi.registerTool() mega-tools. See
844
- // @danypops/papyrus's src/vehicle/papyrus-vehicle.ts for the server side.
344
+ // notes, rules, docs, skills, playbooks, and tasks are no longer registered here -- all migrated
345
+ // onto Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
346
+ // index.ts), replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
347
+ // src/vehicle/papyrus-vehicle.ts for the server side. discuss remains here -- live:true needs an
348
+ // interactive UI round-trip a stateless Vehicle operation can't express.
845
349
  export function registerDomainTools(pi: ExtensionAPI): void {
846
- registerTasksTool(pi);
847
- registerPlaybooksTool(pi);
848
- registerSkillsTool(pi);
849
350
  registerDiscussTool(pi);
850
351
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, artifact.*)
3
- * as real Pi tools -- see @danypops/papyrus's src/vehicle/papyrus-vehicle.ts.
2
+ * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, skills.*,
3
+ * playbooks.*, tasks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
4
+ * src/vehicle/papyrus-vehicle.ts.
4
5
  *
5
6
  * Fails silently on a stale/unreachable daemon handle instead of aborting extension
6
7
  * setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
@@ -14,8 +15,17 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
15
  import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
15
16
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
16
17
  import { currentVehicleClientTarget } from "./service-client.ts";
18
+ import { sessionSecretField } from "./session-identity.ts";
19
+ import { emitTaskFocusEvent } from "./task-focus-events.ts";
17
20
 
18
- const REGISTERED_PERMISSIONS = ["notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write", "artifact:read", "artifact:write"];
21
+ const REGISTERED_PERMISSIONS = [
22
+ "notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write",
23
+ "skills:read", "skills:write", "playbooks:read", "playbooks:write", "tasks:read", "tasks:write",
24
+ "artifact:read", "artifact:write",
25
+ ];
26
+
27
+ /** Task Focus's own internal write needs a real, per-session secret -- see below. Every other tasks.* operation reads session_id purely for read-scoping and needs no secret. */
28
+ const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.unpause", "tasks.clear_focus"]);
19
29
 
20
30
  export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
21
31
  const target = currentVehicleClientTarget();
@@ -25,6 +35,52 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
25
35
  await registerVehicleTools(pi, client, {
26
36
  permissions: REGISTERED_PERMISSIONS,
27
37
  principal: { id: "pi-papyrus" },
38
+ // playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
39
+ // own module handlers, authorize an internal Task Focus write via
40
+ // sessionIdentity.assertAuthorized(session_id, session_secret) -- see
41
+ // @danypops/papyrus's src/vehicle/playbooks-vehicle.ts and tasks-vehicle.ts. That
42
+ // secret must never be a model-visible input field (the model has no business
43
+ // knowing or supplying it), so it travels here instead, in principal.claims, from
44
+ // this extension's own already-cached secret (registered at session_start -- see
45
+ // index.ts) -- the same value sessionSecretField() used to thread through as a raw
46
+ // RPC input field before these operations moved onto Vehicle.
47
+ resolveInvocation: ({ descriptor, input, context }) => {
48
+ if (descriptor.name !== "playbooks.invoke" && !FOCUS_MUTATION_OPERATIONS.has(descriptor.name)) return {};
49
+ // tasks.* defaults session_id to this Pi session's own id, same as the removed
50
+ // hand-rolled tool -- but the secret cache is keyed by whichever session_id is
51
+ // actually being authorized, not blindly this session's, so a model that
52
+ // explicitly overrides session_id to a DIFFERENT session never gets this
53
+ // session's secret smuggled in on its behalf.
54
+ const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
55
+ const sessionId = typeof requestedSessionId === "string" && requestedSessionId.length > 0 ? requestedSessionId : context.sessionManager.getSessionId();
56
+ const { session_secret: sessionSecret } = sessionSecretField(sessionId);
57
+ // Omit sessionSecret entirely when nothing is cached (unregistered session) --
58
+ // {sessionSecret: null} would fail the module's own optionalString(input,
59
+ // "session_secret") check (undefined-or-string, not null), a real regression from
60
+ // sessionSecretField()'s own {} (key omitted) return for the same case.
61
+ const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
62
+ return { principal: { id: "pi-papyrus", claims } };
63
+ },
64
+ // papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
65
+ // token-cost router correlating its own telemetry with the currently focused task)
66
+ // -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
67
+ // than from the operation's own output.
68
+ onInvoked: ({ descriptor }, output) => {
69
+ if (descriptor.name === "tasks.focus") {
70
+ const artifact = output as { id: string } | undefined;
71
+ if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
72
+ return;
73
+ }
74
+ if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
75
+ const focus = output as { artifact: { id: string } } | undefined;
76
+ if (focus?.artifact?.id) emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
77
+ return;
78
+ }
79
+ if (descriptor.name === "tasks.clear_focus") {
80
+ const result = output as { cleared: boolean } | undefined;
81
+ if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
82
+ }
83
+ },
28
84
  });
29
85
  } catch {
30
86
  // Daemon state is stale/unreachable -- degrade silently, matching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.40.0",
3
+ "version": "0.42.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,11 +18,11 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/jittor": "^0.14.0",
21
- "@danypops/papyrus": "^0.39.0",
21
+ "@danypops/papyrus": "^0.41.0",
22
22
  "@danypops/vehicle-core": "^0.2.0",
23
23
  "@danypops/vehicle-server": "^0.1.1",
24
24
  "@danypops/vehicle-client": "^0.1.1",
25
- "@danypops/vehicle-client-pi": "^0.2.0",
25
+ "@danypops/vehicle-client-pi": "^0.3.0",
26
26
  "beautiful-mermaid": "1.1.3",
27
27
  "malevich-tui-components": "^0.5.0"
28
28
  },