@danypops/pi-papyrus 0.38.0 → 0.38.2

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.
@@ -1,14 +1,12 @@
1
1
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import {
4
- NOTE_DISPOSITIONS,
5
4
  PROOF_TYPES,
6
5
  readDiscussionExtra,
7
6
  type Artifact,
8
7
  type DiscussionAndRounds,
9
8
  type DiscussionRound,
10
9
  type GateResult,
11
- type NoteHistoryPage,
12
10
  type OperationName,
13
11
  type WorkflowRunResult,
14
12
  type TaskCompletion,
@@ -250,7 +248,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
250
248
  pi.registerTool({
251
249
  name: "tasks",
252
250
  label: "Tasks",
253
- 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, 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. 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.",
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.",
254
252
  parameters: Type.Object({
255
253
  action: Type.String(),
256
254
  id: Type.Optional(Type.String()),
@@ -411,6 +409,10 @@ export function registerTasksTool(pi: ExtensionAPI): void {
411
409
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
412
410
  return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
413
411
  }
412
+ if (action === "set_gates") {
413
+ const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_gates", params);
414
+ return text(`Updated gates: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_gates", artifact));
415
+ }
414
416
  if (action === "complete") {
415
417
  const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
416
418
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
@@ -487,69 +489,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
487
489
  });
488
490
  }
489
491
 
490
- export function registerNotesTool(pi: ExtensionAPI): void {
491
- pi.registerTool({
492
- name: "notes",
493
- label: "Notes",
494
- description: "Deferred human-intent inbox. ACTIONS: capture, list, show, history, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. history returns this note's own real append-only event log (captured/consumed/promoted/archived), not the generic cross-kind graph.history. PREFER `name` (the note's exact title) over `id` for show/history/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
495
- parameters: Type.Object({
496
- action: Type.String(),
497
- id: Type.Optional(Type.String()),
498
- name: Type.Optional(Type.String()),
499
- body: Type.Optional(Type.String()),
500
- title: Type.Optional(Type.String()),
501
- status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
502
- text: Type.Optional(Type.String()),
503
- limit: Type.Optional(Type.Number()),
504
- target_id: Type.Optional(Type.String()),
505
- target_name: Type.Optional(Type.String()),
506
- disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
507
- reason: Type.Optional(Type.String()),
508
- session_id: Type.Optional(Type.String()),
509
- project_root: Type.Optional(Type.String()),
510
- }),
511
- renderCall(args, theme) { return renderPapyrusToolCall("Notes", args, theme); },
512
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
513
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
514
- try {
515
- const params: Record<string, unknown> = { ...rawParams };
516
- const action = params.action;
517
- const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
518
- await resolveNameFields(params, [
519
- { nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest },
520
- // Kind-agnostic: a promotion target can be a task, doc, rule, or skill, so this searches every kind rather than only notes.
521
- { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest },
522
- ]);
523
- const request = { ...params, ...baseRequest };
524
- if (action === "capture") {
525
- const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
526
- return text(`Captured note ${artifactLine(artifact)}`, createArtifactDetails("notes.capture", artifact));
527
- }
528
- if (action === "list") {
529
- const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", request);
530
- return text(rows.length ? artifactLines(rows).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
531
- }
532
- if (action === "show") {
533
- const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
534
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("notes.show", artifact));
535
- }
536
- if (action === "history") {
537
- const page = await callService<Record<string, unknown>, NoteHistoryPage>("notes.history", request);
538
- const lines = page.events.map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`);
539
- const output = lines.join("\n") || "No recorded history for this note.";
540
- return text(output, createPreviewDetails("notes.history", "Note history", output));
541
- }
542
- const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
543
- const operation = operations[action as keyof typeof operations];
544
- if (!operation) throw new Error(`unknown notes action: ${action}`);
545
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
546
- return text(`${action}: ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
547
- } catch (error) {
548
- throw new Error(`notes failed: ${error instanceof Error ? error.message : error}`);
549
- }
550
- },
551
- });
552
- }
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.
553
495
 
554
496
  export function registerDocsTool(pi: ExtensionAPI): void {
555
497
  pi.registerTool({
@@ -939,7 +881,6 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
939
881
  /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
940
882
  export function registerDomainTools(pi: ExtensionAPI): void {
941
883
  registerTasksTool(pi);
942
- registerNotesTool(pi);
943
884
  registerDocsTool(pi);
944
885
  registerRulesTool(pi);
945
886
  registerPlaybooksTool(pi);
@@ -28,6 +28,7 @@ import { formatMetadata } from "./artifact-format.ts";
28
28
  import { callService, subscribeTaskPushChannel } from "./service-client.ts";
29
29
  import type { PushChannelClient } from "@danypops/daemon-kit/pi-client";
30
30
  import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
31
+ import { registerNotesVehicle } from "./vehicle-notes-client.ts";
31
32
  import { BoundedPoll } from "./bounded-poll.ts";
32
33
  import { renderNoteWidgetLines } from "./note-widget.ts";
33
34
  import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
@@ -677,6 +678,18 @@ export default async function (pi: ExtensionAPI) {
677
678
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
678
679
 
679
680
  pi.on("session_start", async (_event, ctx) => {
681
+ // registerVehicleTools() (which registerNotesVehicle wraps) needs
682
+ // pi.getAllTools()/getActiveTools()/setActiveTools() -- Pi's extension
683
+ // runtime only finishes initializing after every extension's top-level
684
+ // factory (this one included) has resolved, so calling it directly from
685
+ // there throws "Extension runtime not initialized" (previously silently
686
+ // swallowed by registerNotesVehicle's own daemon-unreachable try/catch,
687
+ // making every projected notes.* tool invisible to the model with zero
688
+ // visible sign why -- confirmed live in the identical pi-tickets bug).
689
+ // session_start fires only after that initialization completes, and Pi
690
+ // awaits every session_start handler before the model's first turn, so
691
+ // registering here is both safe and still visible on turn one.
692
+ await registerNotesVehicle(pi);
680
693
  // Registers this session's identity with the daemon as early as possible -- before any
681
694
  // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
682
695
  // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
@@ -731,7 +744,9 @@ export default async function (pi: ExtensionAPI) {
731
744
  if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
732
745
  await overlay?.refresh();
733
746
  }
734
- if (event.toolName === "notes") {
747
+ // notes.* projects to notes_capture/notes_list/notes_show/... (see
748
+ // registerNotesVehicle) -- not a single "notes" tool name anymore.
749
+ if (event.toolName.startsWith("notes_")) {
735
750
  await noteOverlay?.refresh();
736
751
  }
737
752
  });
@@ -1,5 +1,5 @@
1
1
  import { connectPushChannel, createRetryingClient, type PushChannelClient, type PushChannelState, type RetryingClient } from "@danypops/daemon-kit/pi-client";
2
- import { connectPapyrusClient, resolvePushChannelTarget, type OperationName, type PapyrusClient } from "@danypops/papyrus";
2
+ import { connectPapyrusClient, resolvePushChannelTarget, resolveVehicleClientTarget, type OperationName, type PapyrusClient, type VehicleClientTarget } from "@danypops/papyrus";
3
3
 
4
4
  type ClientConnector = () => Promise<PapyrusClient>;
5
5
 
@@ -37,6 +37,27 @@ export function resetPushChannelTargetResolverForTests(): void {
37
37
  pushChannelTargetResolver = resolvePushChannelTarget;
38
38
  }
39
39
 
40
+ let vehicleClientTargetResolver: typeof resolveVehicleClientTarget = resolveVehicleClientTarget;
41
+
42
+ /**
43
+ * Defaults to the real daemonStateDir() -- every test that exercises the full extension
44
+ * entrypoint (registerPapyrus(api)), not just registerDomainTools, must override this
45
+ * first, the same way mockService already overrides setPapyrusClientConnectorForTests,
46
+ * or a hermetic unit test can silently start depending on whatever real Papyrus daemon
47
+ * handle happens to exist on the machine running it.
48
+ */
49
+ export function setVehicleClientTargetResolverForTests(value: () => VehicleClientTarget | undefined): void {
50
+ vehicleClientTargetResolver = value;
51
+ }
52
+
53
+ export function resetVehicleClientTargetResolverForTests(): void {
54
+ vehicleClientTargetResolver = resolveVehicleClientTarget;
55
+ }
56
+
57
+ export function currentVehicleClientTarget(): VehicleClientTarget | undefined {
58
+ return vehicleClientTargetResolver();
59
+ }
60
+
40
61
  /**
41
62
  * Subscribes to the daemon's "tasks" push topic so a widget can refresh the moment
42
63
  * a mutation happens, instead of waiting for its next poll tick. Returns undefined
@@ -0,0 +1,43 @@
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).
7
+ *
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.
17
+ *
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.
24
+ */
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
27
+ import { registerVehicleTools } from "@danypops/vehicle-client-pi";
28
+ import { currentVehicleClientTarget } from "./service-client.ts";
29
+
30
+ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
31
+ const target = currentVehicleClientTarget();
32
+ if (!target) return;
33
+ try {
34
+ const client = new RemoteVehicleClient({ baseUrl: target.baseUrl, token: target.token });
35
+ await registerVehicleTools(pi, client, {
36
+ permissions: ["notes:read", "notes:write"],
37
+ principal: { id: "pi-papyrus" },
38
+ });
39
+ } catch {
40
+ // Daemon state is stale/unreachable -- degrade silently, matching
41
+ // subscribeTaskPushChannel's own tolerance for the same condition.
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
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"],
@@ -19,6 +19,9 @@
19
19
  "dependencies": {
20
20
  "@danypops/daemon-kit": "^0.10.0",
21
21
  "@danypops/papyrus": "^0.38.0",
22
+ "@danypops/vehicle-core": "^0.1.0",
23
+ "@danypops/vehicle-client": "^0.1.1",
24
+ "@danypops/vehicle-client-pi": "^0.1.5",
22
25
  "beautiful-mermaid": "1.1.3"
23
26
  },
24
27
  "devDependencies": {