@danypops/pi-papyrus 0.38.4 → 0.39.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.
@@ -8,6 +8,8 @@ import {
8
8
  type DiscussionRound,
9
9
  type GateResult,
10
10
  type OperationName,
11
+ type PlaybookInvocationResult,
12
+ type PlaybookMissingArguments,
11
13
  type WorkflowRunResult,
12
14
  type TaskCompletion,
13
15
  type TaskExecutionPlan,
@@ -138,16 +140,49 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
138
140
  return matches[0]!.id;
139
141
  }
140
142
 
143
+ /**
144
+ * tasks.list is the one list operation that requires `project_root` and separately supports a
145
+ * `scope` ("project" | "graph" | "all") to widen or narrow the search. Every other list operation
146
+ * (docs.list, rules.list, skills.list, playbooks.list, artifact.query, ...) instead treats an
147
+ * omitted `project_root` as an unscoped/global search (domain-services.ts's listScoped) and has
148
+ * no `scope` concept at all -- so "search everywhere" means something different for each.
149
+ */
150
+ const SCOPE_AWARE_LIST_OPERATIONS = new Set<OperationName>(["tasks.list"]);
151
+
152
+ /** The widened-scope request tried once when a name isn't found under the caller's current scope. */
153
+ function widenedRequest(listOperation: OperationName, baseRequest: Record<string, unknown>): Record<string, unknown> {
154
+ return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation)
155
+ ? { ...baseRequest, scope: "all" }
156
+ : { ...baseRequest, project_root: undefined };
157
+ }
158
+
141
159
  /**
142
160
  * Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
143
161
  * scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
144
162
  * kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
145
163
  * should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
146
164
  * uses, so resolution never searches a wider or narrower scope than a plain list call would.
165
+ *
166
+ * A two-artifact action (depend/contain/gate/link) routinely names artifacts that live in two
167
+ * different projects, and one call has no way to give two different name fields two different
168
+ * scopes. When the first lookup finds nothing under the caller's current scope, retry exactly
169
+ * once against a global search before giving up -- but never when the caller already pinned an
170
+ * explicit `scope`, so a genuine "not found in the scope I asked for" stays a real error instead
171
+ * of being silently papered over. `notes`, when given, records that a name only resolved after
172
+ * widening, so the caller can surface that a search went wider than the caller's default scope
173
+ * rather than resolving silently.
147
174
  */
148
- async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string): Promise<string> {
175
+ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string, notes?: string[]): Promise<string> {
149
176
  const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
150
- return matchArtifactByName(candidates, name);
177
+ try {
178
+ return matchArtifactByName(candidates, name);
179
+ } catch (error) {
180
+ if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest["scope"] !== undefined) throw error;
181
+ const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...widenedRequest(listOperation, baseRequest), text: name });
182
+ const id = matchArtifactByName(widenedCandidates, name);
183
+ notes?.push(`"${name}" was not found in the current project scope; resolved across all projects instead.`);
184
+ return id;
185
+ }
151
186
  }
152
187
 
153
188
  /**
@@ -167,15 +202,21 @@ export function normalizeJsonEncodedField(params: Record<string, unknown>, key:
167
202
  }
168
203
  }
169
204
 
170
- /** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
205
+ /**
206
+ * Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in
207
+ * place. `notes`, when given, collects a message for each name that only resolved by widening
208
+ * past the caller's own scope (see resolveArtifactIdByName) -- callers that want that surfaced
209
+ * to the model/human pass an array here and append it to their own response text.
210
+ */
171
211
  export async function resolveNameFields(
172
212
  params: Record<string, unknown>,
173
213
  fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
214
+ notes?: string[],
174
215
  ): Promise<void> {
175
216
  for (const { nameKey, idKey, listOperation, baseRequest } of fields) {
176
217
  const nameValue = params[nameKey];
177
218
  if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
178
- params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue);
219
+ params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue, notes);
179
220
  }
180
221
  }
181
222
  }
@@ -187,10 +228,11 @@ async function resolveNameArrayField(
187
228
  idsKey: string,
188
229
  listOperation: OperationName,
189
230
  baseRequest: Record<string, unknown>,
231
+ notes?: string[],
190
232
  ): Promise<void> {
191
233
  const names = params[namesKey];
192
234
  if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
193
- params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry))));
235
+ params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry), notes)));
194
236
  }
195
237
  }
196
238
 
@@ -248,7 +290,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
248
290
  pi.registerTool({
249
291
  name: "tasks",
250
292
  label: "Tasks",
251
- 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; `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the same pattern for their `_id` counterparts.",
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.",
252
294
  parameters: Type.Object({
253
295
  action: Type.String(),
254
296
  id: Type.Optional(Type.String()),
@@ -299,12 +341,17 @@ export function registerTasksTool(pi: ExtensionAPI): void {
299
341
  // ever holds this extension's own registered session anyway (see session-identity.ts).
300
342
  const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
301
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[] = [];
302
349
  // Resolve the graph root first: every other name lookup must use the caller's final
303
350
  // project/scope/root selection, otherwise `scope: all|graph` silently collapses back
304
351
  // to the current project and forces callers to reach for an id.
305
352
  await resolveNameFields(params, [
306
353
  { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
307
- ]);
354
+ ], notes);
308
355
  const resolutionRequest = {
309
356
  ...baseRequest,
310
357
  ...(params.scope === undefined ? {} : { scope: params.scope }),
@@ -317,9 +364,10 @@ export function registerTasksTool(pi: ExtensionAPI): void {
317
364
  { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
318
365
  { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
319
366
  { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
320
- ]);
321
- await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest);
367
+ ], notes);
368
+ await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
322
369
  const request = { ...params, ...baseRequest };
370
+ const result = await (async (): Promise<ReturnType<typeof text>> => {
323
371
  if (action === "create") {
324
372
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
325
373
  return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
@@ -482,6 +530,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
482
530
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
483
531
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
484
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;
485
536
  } catch (error) {
486
537
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
487
538
  }
@@ -489,121 +540,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
489
540
  });
490
541
  }
491
542
 
492
- // notes.* is registered as a real Vehicle (see ../vehicle-notes-client.ts and index.ts),
493
- // not a hand-rolled pi.registerTool() -- the first domain migrated off the
494
- // action-dispatch mega-tool pattern this file's other tools still use.
495
-
496
- export function registerDocsTool(pi: ExtensionAPI): void {
497
- pi.registerTool({
498
- name: "docs",
499
- label: "Documents",
500
- description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, update, remove, remove_subtree, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline; remove_subtree extends this to a whole `contains` subtree in one call. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
501
- parameters: Type.Object({
502
- action: Type.String(),
503
- id: Type.Optional(Type.String()),
504
- name: Type.Optional(Type.String()),
505
- title: Type.Optional(Type.String()),
506
- body: Type.Optional(Type.String()),
507
- subtype: Type.Optional(Type.String()),
508
- status: Type.Optional(Type.String()),
509
- text: Type.Optional(Type.String()),
510
- limit: Type.Optional(Type.Number()),
511
- labels: Type.Optional(Type.Array(Type.String())),
512
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
513
- template_id: Type.Optional(Type.String()),
514
- relation: Type.Optional(Type.String()),
515
- target_id: Type.Optional(Type.String()),
516
- target_name: Type.Optional(Type.String()),
517
- project_root: Type.Optional(Type.String()),
518
- reason: Type.Optional(Type.String()),
519
- }),
520
- renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
521
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
522
- async execute(_id, rawParams) {
523
- try {
524
- const params: Record<string, unknown> = { ...rawParams };
525
- const action = params.action;
526
- const scopeRequest = { project_root: params.project_root };
527
- await resolveNameFields(params, [
528
- { nameKey: "name", idKey: "id", listOperation: "docs.list", baseRequest: scopeRequest },
529
- // Kind-agnostic: a link target can be a doc, task, rule, or skill, so this searches every kind rather than only docs.
530
- { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest: scopeRequest },
531
- ]);
532
- if (action === "create") {
533
- const artifact = await callService<Record<string, unknown>, Artifact>("docs.create", params);
534
- return text(`Created document ${artifactLine(artifact)}`, createArtifactDetails("docs.create", artifact));
535
- }
536
- if (action === "list") {
537
- const rows = await callService<Record<string, unknown>, Artifact[]>("docs.list", params);
538
- return text(rows.length ? artifactLines(rows).join("\n") : "No documents found.", createArtifactListDetails("docs.list", rows));
539
- }
540
- if (action === "show") {
541
- const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
542
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("docs.show", artifact));
543
- }
544
- const trashResult = await handleArtifactRemoveRestore(action, params);
545
- if (trashResult) return trashResult;
546
- const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project", update: "docs.update" } as const;
547
- const operation = operations[action as keyof typeof operations];
548
- if (!operation) throw new Error(`unknown docs action: ${action}`);
549
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
550
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
551
- } catch (error) {
552
- throw new Error(`docs failed: ${error instanceof Error ? error.message : error}`);
553
- }
554
- },
555
- });
556
- }
557
-
558
- export function registerRulesTool(pi: ExtensionAPI): void {
559
- pi.registerTool({
560
- name: "rules",
561
- label: "Rules",
562
- description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, update, remove, remove_subtree, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. update changes title/body/labels (at least one required); body updates still enforce the same combined condition+action+body context-tax bound as creation, and are refused for a read-only external projection. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline; remove_subtree extends this to a whole `contains` subtree in one call. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
563
- parameters: Type.Object({
564
- action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
565
- body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
566
- severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
567
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
568
- text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
569
- task_name: Type.Optional(Type.String()),
570
- project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
571
- }),
572
- renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
573
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
574
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
575
- try {
576
- const params: Record<string, unknown> = { ...rawParams };
577
- const action = params.action;
578
- await resolveNameFields(params, [
579
- { nameKey: "name", idKey: "id", listOperation: "rules.list", baseRequest: { project_root: params.project_root } },
580
- { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: { project_root: params.project_root ?? ctx.cwd } },
581
- ]);
582
- if (action === "create") {
583
- const artifact = await callService<Record<string, unknown>, Artifact>("rules.create", params);
584
- return text(`Created rule ${artifactLine(artifact)}`, createArtifactDetails("rules.create", artifact));
585
- }
586
- if (action === "list") {
587
- const rows = await callService<Record<string, unknown>, Artifact[]>("rules.list", params);
588
- return text(rows.length ? artifactLines(rows).join("\n") : "No rules found.", createArtifactListDetails("rules.list", rows));
589
- }
590
- if (action === "preview") {
591
- const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
592
- return text(preview, createPreviewDetails("rules.preview", "Rule preview", preview));
593
- }
594
- const trashResult = await handleArtifactRemoveRestore(action, params);
595
- if (trashResult) return trashResult;
596
- const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project", update: "rules.update" } as const;
597
- const operation = operations[action as keyof typeof operations];
598
- if (!operation) throw new Error(`unknown rules action: ${action}`);
599
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
600
- return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
601
- } catch (error) {
602
- throw new Error(`rules failed: ${error instanceof Error ? error.message : error}`);
603
- }
604
- },
605
- });
606
- }
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.
607
546
 
608
547
  export function registerPlaybooksTool(pi: ExtensionAPI): void {
609
548
  pi.registerTool({
@@ -672,13 +611,33 @@ export function registerPlaybooksTool(pi: ExtensionAPI): void {
672
611
  return text(rendered, createPreviewDetails("playbooks.preview", "Playbook preview", rendered));
673
612
  }
674
613
  if (action === "invoke") {
675
- const invocation = await callService<Record<string, unknown>, { entryTaskId?: string; rootTaskIds?: string[]; created?: { tasks: string[] }; missingArguments?: string[] }>("playbooks.invoke", params);
676
- if (invocation.missingArguments) {
614
+ const invocation = await callService<Record<string, unknown>, PlaybookInvocationResult | PlaybookMissingArguments>("playbooks.invoke", params);
615
+ if ("missingArguments" in invocation) {
677
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.`;
678
- return text(message, createPreviewDetails("playbooks.invoke", "Playbook invocation", message));
617
+ return text(message, createInvocationDetails("playbooks.invoke", invocation.playbookId, { tasks: [], docs: [], rules: [], roots: [] }));
679
618
  }
680
- const message = `Invoked: ${invocation.created?.tasks.length ?? 0} task(s) created, entry task ${invocation.entryTaskId} now focused. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`;
681
- return text(message, createPreviewDetails("playbooks.invoke", "Playbook invocation", JSON.stringify(invocation, null, 2)));
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
+ }));
682
641
  }
683
642
  const trashResult = await handleArtifactRemoveRestore(action, params);
684
643
  if (trashResult) return trashResult;
@@ -879,10 +838,12 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
879
838
  }
880
839
 
881
840
  /** 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.
882
845
  export function registerDomainTools(pi: ExtensionAPI): void {
883
846
  registerTasksTool(pi);
884
- registerDocsTool(pi);
885
- registerRulesTool(pi);
886
847
  registerPlaybooksTool(pi);
887
848
  registerSkillsTool(pi);
888
849
  registerDiscussTool(pi);
@@ -1,39 +1,29 @@
1
1
  /**
2
- * Registers notes.* as a real Vehicle instead of a hand-rolled `pi.registerTool()`
3
- * mega-tool -- see @danypops/papyrus's src/vehicle/notes-vehicle.ts for the
4
- * VehicleRegistry side. Same daemon, same handle file, same Bearer token every
5
- * other Papyrus RPC call already uses (resolveVehicleClientTarget mirrors
6
- * resolvePushChannelTarget's own resolution).
2
+ * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, artifact.*)
3
+ * as real Pi tools -- see @danypops/papyrus's src/vehicle/papyrus-vehicle.ts.
7
4
  *
8
- * Papyrus's daemon is expected to already be running as an installed service
9
- * (see `packed install-service`), not auto-spawned on first use -- so unlike
10
- * registerVehicleTools' README example, failure here (daemon not started yet,
11
- * stale handle) is tolerated the same silent-degrade way
12
- * subscribeTaskPushChannel already tolerates it, rather than letting a
13
- * daemon-not-running condition abort the rest of extension setup. There is
14
- * no retry-on-later-connect for a tool that was never registered at all --
15
- * Pi has no way to add one after the fact outside the initial registration
16
- * flow.
5
+ * Fails silently on a stale/unreachable daemon handle instead of aborting extension
6
+ * setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
7
+ * here has no later retry path.
17
8
  *
18
- * Resolves the target through service-client.ts's currentVehicleClientTarget()
19
- * (test-injectable, see setVehicleClientTargetResolverForTests), never
20
- * @danypops/papyrus's resolveVehicleClientTarget() directly -- this runs from
21
- * the bare extension entrypoint on every registerPapyrus(api) call, so an
22
- * un-injected default would resolve the real daemonStateDir() in any test
23
- * exercising the full entrypoint, not just ones about notes.
9
+ * Uses service-client.ts's currentVehicleClientTarget() (test-injectable) rather
10
+ * than resolveVehicleClientTarget() directly, so a test exercising the full
11
+ * extension entrypoint doesn't resolve a real daemonStateDir().
24
12
  */
25
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
14
  import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
27
15
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
28
16
  import { currentVehicleClientTarget } from "./service-client.ts";
29
17
 
18
+ const REGISTERED_PERMISSIONS = ["notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write", "artifact:read", "artifact:write"];
19
+
30
20
  export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
31
21
  const target = currentVehicleClientTarget();
32
22
  if (!target) return;
33
23
  try {
34
24
  const client = new RemoteVehicleClient({ baseUrl: target.baseUrl, token: target.token });
35
25
  await registerVehicleTools(pi, client, {
36
- permissions: ["notes:read", "notes:write"],
26
+ permissions: REGISTERED_PERMISSIONS,
37
27
  principal: { id: "pi-papyrus" },
38
28
  });
39
29
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.38.4",
3
+ "version": "0.39.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"],
@@ -17,7 +17,7 @@
17
17
  "typebox": "*"
18
18
  },
19
19
  "dependencies": {
20
- "@danypops/papyrus": "^0.38.1",
20
+ "@danypops/papyrus": "^0.39.0",
21
21
  "@danypops/vehicle-core": "^0.1.1",
22
22
  "@danypops/vehicle-server": "^0.1.1",
23
23
  "@danypops/vehicle-client": "^0.1.1",