@danypops/pi-papyrus 0.41.0 → 0.43.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,13 @@ 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. `tasks` and `discuss` are still single tools with an `action` parameter; `notes`, `docs`, `rules`, `skills`, and `playbooks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, and so on) -- no `action` dispatch, each with its own schema:
13
+ Agent-facing domain tools own lifecycle invariants and sit above this store API. `discuss` is still a single tool with an `action` parameter -- `live:true` needs an interactive UI round-trip a stateless Vehicle operation can't express. `notes`, `docs`, `rules`, `playbooks`, and `tasks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `playbooks_invoke`, `tasks_complete`, and so on) -- no `action` dispatch, each with its own schema:
14
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** (`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
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/Playbook, or archive it with an explicit disposition
17
17
  - **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
18
18
  - **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
19
- - **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
19
+ - **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss` with `live:true` rather than guess
21
20
 
22
21
  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
22
 
@@ -28,15 +27,14 @@ Every tool operation is registered in the daemon's `/api/v1/ops` registry; parit
28
27
  - `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
29
28
  - `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
30
29
  - `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
31
- - `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
32
30
  - `/playbooks` — trigger/tools rows, edit, invocation into the editor, and graph links
33
31
  - `/playbook <name>` — tab-completes active playbook titles and places that one's invocation directly in the editor, one step instead of browse-then-select; no argument falls back to the full `/playbooks` browser
34
32
 
35
- All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors; relationships render as a small Unicode graph via `beautiful-mermaid` when the neighbor set is real and within the routed-rendering bound, falling back to a plain, still name-resolved arrow list otherwise. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
33
+ All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, and Playbooks. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors; relationships render as a small Unicode graph via `beautiful-mermaid` when the neighbor set is real and within the routed-rendering bound, falling back to a plain, still name-resolved arrow list otherwise. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
36
34
 
37
35
  ## Notes
38
36
 
39
- Notes are project-scoped `doc/note` artifacts for human requests that should be considered later. Capturing a Note does not create work, inject the entire inbox into prompts, or imply acceptance. The agent can use the `notes` domain tool to list and consume open Notes, decide whether to create a Task, Doc, Rule, or Skill through its owning domain tool, then promote the Note by linking that artifact. Archive requires one of `completed`, `duplicate`, `declined`, or `superseded`; promote archives with a `promoted` disposition and target ID. Capture, consumption, and disposition provenance remain in bounded Note history.
37
+ Notes are project-scoped `doc/note` artifacts for human requests that should be considered later. Capturing a Note does not create work, inject the entire inbox into prompts, or imply acceptance. The agent can use the `notes` domain tool to list and consume open Notes, decide whether to create a Task, Doc, Rule, or Playbook through its owning domain tool, then promote the Note by linking that artifact. Archive requires one of `completed`, `duplicate`, `declined`, or `superseded`; promote archives with a `promoted` disposition and target ID. Capture, consumption, and disposition provenance remain in bounded Note history.
40
38
 
41
39
  The default inbox contains draft and consumed/active Notes, is bounded to 50 rows, and has a hard limit of 200. Bodies are capped at 10,000 characters. Generic document and graph lifecycle operations reject Note mutations so they cannot bypass disposition provenance.
42
40
 
@@ -45,7 +43,7 @@ papyrus notes capture "Investigate the retry policy" --json
45
43
  papyrus notes list --limit 25 --json
46
44
  papyrus notes show <note-id> --json
47
45
  papyrus notes consume <note-id> --json
48
- # Create the resulting artifact with tasks/docs/rules/skills first, then:
46
+ # Create the resulting artifact with tasks/docs/rules/playbooks first, then:
49
47
  papyrus notes promote <note-id> <target-id> --reason "Converted to tracked work" --json
50
48
  papyrus notes archive <note-id> declined --reason "No longer relevant" --json
51
49
  ```
@@ -62,7 +60,7 @@ Blocking is real: `tasks.complete` is refused while any `active` Discussion has
62
60
 
63
61
  `open`/`reply` can also pose a structured choice instead of (or alongside) free text: `options` (2-10 entries) plus `options_mode` -- `single` is mutually exclusive (exactly one pick), `multi` allows several. The Discussion remembers the pending choice (`extra.discussion.pendingOptions`/`pendingOptionsMode`) until a `reply` answers it with `selected`, validated against exactly what was offered and the mode's cardinality; a reply can also pose the *next* round's choice in the same call.
64
62
 
65
- Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select for `multi`, since no built-in multi-select exists in the Pi extension UI. Both modes append a numbered "type your own answer" row -- a genuinely open answer is exactly as valid as any posed option. The multi-select picker supports a number key as a direct quick-select (jump straight to that row instead of scrolling), and steadily highlights checked rows while dimming the rest so the eye reads "what's chosen" independent of cursor position; its cursor row blinks to mark focus. It also auto-cancels after 30s of zero input -- the very first keystroke of any kind stops that countdown permanently for that prompt. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
63
+ Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select for `multi`, since no built-in multi-select exists in the Pi extension UI. Both modes append a numbered "type your own answer" row -- a genuinely open answer is exactly as valid as any posed option. The multi-select picker supports a number key as a direct quick-select (jump straight to that row instead of scrolling), and steadily highlights checked rows while dimming the rest so the eye reads "what's chosen" independent of cursor position; its cursor row blinks to mark focus. It also auto-cancels after 30s of zero input -- the very first keystroke of any kind stops that countdown permanently for that prompt. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Playbooks) -- `/discuss` browses and drives existing ones.
66
64
 
67
65
  ```bash
68
66
  papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
@@ -119,7 +117,7 @@ papyrus tasks cancel <id> --json
119
117
  papyrus tasks cancel-subtree <id> --json
120
118
  ```
121
119
 
122
- `cancel-subtree` cancels a Task and every Task in its containment (`contains`) subtree in one call -- for tearing down a whole materialized Playbook/Skill run at once instead of canceling each Task id by hand. A Task already `done`/`canceled` is skipped, not treated as an error.
120
+ `cancel-subtree` cancels a Task and every Task in its containment (`contains`) subtree in one call -- for tearing down a whole materialized Playbook run at once instead of canceling each Task id by hand. A Task already `done`/`canceled` is skipped, not treated as an error.
123
121
 
124
122
  Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact. The same `update` action provides a narrowly guarded recovery for Tasks accidentally created terminal by a legacy default: `status=todo` requires an audit reason, cannot be combined with content edits, only applies when `created` is the sole lifecycle event, and appends `creation_recovered` rather than rewriting history.
125
123
 
@@ -133,10 +131,10 @@ Checklist criteria are an item-to-proof map. Every new item requires one or more
133
131
 
134
132
  ```ts
135
133
  checklist: {
136
- "Write failing skill-row tests": {
134
+ "Write failing playbook-row tests": {
137
135
  proof: [
138
136
  { type: "file", target: "test/frontends.test.ts" },
139
- { type: "symbol", target: "test/frontends.test.ts#skill row test" }
137
+ { type: "symbol", target: "test/frontends.test.ts#playbook row test" }
140
138
  ]
141
139
  }
142
140
  }
@@ -3,8 +3,8 @@ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
3
  /**
4
4
  * Shared {label, glyph, color} shape, mirroring task-presentation.ts's TASK_STATUS_PRESENTATION
5
5
  * for every other artifact kind's status. Centralizing this closes a real gap: every artifact
6
- * browser (Rules, Docs, Notes, Skills) previously rendered status as a bare glyph with no color at
7
- * all, which is exactly why "hard to understand which rules are active" was a real complaint --
6
+ * browser (Rules, Docs, Notes, Playbooks) previously rendered status as a bare glyph with no color
7
+ * at all, which is exactly why "hard to understand which rules are active" was a real complaint --
8
8
  * an active rule's "●" and a deprecated rule's "○" differ only by one filled-vs-hollow pixel shape,
9
9
  * easy to miss at a glance across a scrolling list.
10
10
  */
@@ -31,11 +31,6 @@ export const NOTE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
31
31
  archived: { label: "archived", glyph: "■", color: "dim" },
32
32
  };
33
33
 
34
- export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
35
- active: { label: "active", glyph: "●", color: "success" },
36
- deprecated: { label: "deprecated", glyph: "○", color: "muted" },
37
- };
38
-
39
34
  export const PLAYBOOK_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
40
35
  active: { label: "active", glyph: "●", color: "success" },
41
36
  deprecated: { label: "deprecated", glyph: "○", color: "muted" },
@@ -9,7 +9,7 @@
9
9
  * surfaces severity and notes.ts surfaces history count, both also not the row glyph.
10
10
  *
11
11
  * Creating a new Discussion is left to the agent (the discuss tool), matching docs.ts/rules.ts/
12
- * skills.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
12
+ * playbooks.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
13
13
  * because Notes exists specifically as a human-authored inbox.
14
14
  */
15
15
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
@@ -1,31 +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 TaskCompletion,
12
- type TaskExecutionPlan,
13
- type TaskGraph,
14
- type TaskHistoryPage,
15
- type TaskLease,
16
- type TaskViewSelection,
17
9
  } from "@danypops/papyrus";
18
10
  import { askQuestion } from "./discuss-ask-view.ts";
19
- import { emitTaskFocusEvent } from "./task-focus-events.ts";
20
- import { sessionSecretField } from "./session-identity.ts";
21
11
  import { callService } from "./service-client.ts";
22
12
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
23
13
  import {
24
14
  createArtifactDetails,
25
15
  createArtifactListDetails,
26
- createGateRunDetails,
27
- createGraphDetails,
28
- createInvocationDetails,
29
16
  createModelContent,
30
17
  createPreviewDetails,
31
18
  } from "./tool-rendering/render-model.ts";
@@ -112,15 +99,6 @@ export function artifactLines(artifacts: Artifact[]): string[] {
112
99
  return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
113
100
  }
114
101
 
115
- /** Resolves internal ids for model text; ids resurface only when equal titles need disambiguation. */
116
- async function artifactLabelsById(ids: readonly string[]): Promise<Map<string, string>> {
117
- const uniqueIds = [...new Set(ids)];
118
- const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
119
- const titleCounts = new Map<string, number>();
120
- for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
121
- return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
122
- }
123
-
124
102
  /**
125
103
  * Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
126
104
  * a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
@@ -140,7 +118,7 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
140
118
  /**
141
119
  * tasks.list is the one list operation that requires `project_root` and separately supports a
142
120
  * `scope` ("project" | "graph" | "all") to widen or narrow the search. Every other list operation
143
- * (docs.list, rules.list, skills.list, playbooks.list, artifact.query, ...) instead treats an
121
+ * (docs.list, rules.list, playbooks.list, artifact.query, ...) instead treats an
144
122
  * omitted `project_root` as an unscoped/global search (domain-services.ts's listScoped) and has
145
123
  * no `scope` concept at all -- so "search everywhere" means something different for each.
146
124
  */
@@ -155,7 +133,7 @@ function widenedRequest(listOperation: OperationName, baseRequest: Record<string
155
133
 
156
134
  /**
157
135
  * Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
158
- * scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
136
+ * scope -- tasks.list, docs.list, rules.list, playbooks.list, notes.list, discuss.list, or the
159
137
  * kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
160
138
  * should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
161
139
  * uses, so resolution never searches a wider or narrower scope than a plain list call would.
@@ -217,7 +195,7 @@ async function resolveNameArrayField(
217
195
  }
218
196
 
219
197
  /**
220
- * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
198
+ * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/playbooks) --
221
199
  * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
222
200
  * not owned by any one domain module, so every domain tool exposes the same two actions
223
201
  * over the same two operations rather than reinventing trash semantics four times.
@@ -256,271 +234,7 @@ async function handleArtifactRemoveRestore(action: unknown, params: Record<strin
256
234
  return null;
257
235
  }
258
236
 
259
- const proofReferenceSchema = Type.Object({
260
- type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
261
- target: Type.String(),
262
- expect: Type.Optional(Type.String()),
263
- });
264
-
265
- const checklistCriterionSchema = Type.Object({
266
- proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
267
- });
268
-
269
- export function registerTasksTool(pi: ExtensionAPI): void {
270
- pi.registerTool({
271
- name: "tasks",
272
- label: "Tasks",
273
- 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.",
274
- parameters: Type.Object({
275
- action: Type.String(),
276
- id: Type.Optional(Type.String()),
277
- name: Type.Optional(Type.String()),
278
- title: Type.Optional(Type.String()),
279
- body: Type.Optional(Type.String()),
280
- status: Type.Optional(Type.String()),
281
- text: Type.Optional(Type.String()),
282
- limit: Type.Optional(Type.Number()),
283
- cursor: Type.Optional(Type.Number()),
284
- direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
285
- reason: Type.Optional(Type.String()),
286
- session_id: Type.Optional(Type.String()),
287
- labels: Type.Optional(Type.Array(Type.String())),
288
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
289
- gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
290
- checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
291
- template_id: Type.Optional(Type.String()),
292
- parent_id: Type.Optional(Type.String()),
293
- parent_name: Type.Optional(Type.String()),
294
- child_id: Type.Optional(Type.String()),
295
- child_name: Type.Optional(Type.String()),
296
- dependency_id: Type.Optional(Type.String()),
297
- dependency_name: Type.Optional(Type.String()),
298
- depends_on: Type.Optional(Type.Array(Type.String())),
299
- depends_on_names: Type.Optional(Type.Array(Type.String())),
300
- project_root: Type.Optional(Type.String()),
301
- scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
302
- root_task_id: Type.Optional(Type.String()),
303
- root_task_name: Type.Optional(Type.String()),
304
- owner: Type.Optional(Type.String()),
305
- token: Type.Optional(Type.String()),
306
- ttl_ms: Type.Optional(Type.Number()),
307
- note: Type.Optional(Type.String()),
308
- event_types: Type.Optional(Type.Array(Type.String())),
309
- }),
310
- renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
311
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
312
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
313
- try {
314
- const params: Record<string, unknown> = { ...rawParams };
315
- const action = params.action;
316
- // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
317
- // without depending on the model to know or supply its own session identity.
318
- // session_secret is looked up by the resolved session_id itself (not blindly the
319
- // current session's), so a model that explicitly overrides session_id to a DIFFERENT
320
- // session never gets this session's secret smuggled in on its behalf -- the cache only
321
- // ever holds this extension's own registered session anyway (see session-identity.ts).
322
- const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
323
- const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
324
- // Collects a note whenever a name field below only resolved by widening past this call's
325
- // own project scope (see resolveArtifactIdByName) -- surfaced at the end of this action's
326
- // own response text rather than resolved silently, since a cross-project depend/contain
327
- // is exactly the case a shared per-call scope can't otherwise express.
328
- const notes: string[] = [];
329
- // Resolve the graph root first: every other name lookup must use the caller's final
330
- // project/scope/root selection, otherwise `scope: all|graph` silently collapses back
331
- // to the current project and forces callers to reach for an id.
332
- await resolveNameFields(params, [
333
- { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
334
- ], notes);
335
- const resolutionRequest = {
336
- ...baseRequest,
337
- ...(params.scope === undefined ? {} : { scope: params.scope }),
338
- ...(params.root_task_id === undefined ? {} : { root_task_id: params.root_task_id }),
339
- };
340
- // The daemon remains keyed by stable ids; the agent facade resolves names against the
341
- // exact requested view before dispatching those internal ids.
342
- await resolveNameFields(params, [
343
- { nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest: resolutionRequest },
344
- { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
345
- { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
346
- { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
347
- ], notes);
348
- await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
349
- const request = { ...params, ...baseRequest };
350
- const result = await (async (): Promise<ReturnType<typeof text>> => {
351
- if (action === "create") {
352
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
353
- return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
354
- }
355
- if (action === "list") {
356
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
357
- return text(rows.length ? artifactLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
358
- }
359
- if (action === "show") {
360
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
361
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
362
- }
363
- if (action === "history") {
364
- const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
365
- const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
366
- const output = lines.join("\n") || "No recorded history for this task.";
367
- return text(output, createPreviewDetails("tasks.history", "Task history", output));
368
- }
369
- if (action === "scope") {
370
- const selection = await callService<Record<string, unknown>, TaskViewSelection>("tasks.scope", request);
371
- return text(`Task scope: ${selection.label}`, createPreviewDetails("tasks.scope", "Task scope", selection.label));
372
- }
373
- if (action === "active") {
374
- const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
375
- return artifact
376
- ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
377
- : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
378
- }
379
- if (action === "focused") {
380
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
381
- return focus
382
- ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
383
- : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
384
- }
385
- if (action === "pause" || action === "unpause") {
386
- const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
387
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
388
- emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
389
- return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
390
- }
391
- if (action === "clear_focus") {
392
- const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
393
- if (result.cleared) emitTaskFocusEvent({ taskId: null, sessionId: request.session_id as string, status: "cleared" });
394
- const output = result.cleared ? "Task focus cleared." : "No focused task.";
395
- return text(output, createPreviewDetails("tasks.clear_focus", "Task focus", output));
396
- }
397
- if (action === "graph") {
398
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
399
- const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
400
- const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
401
- const edges = graph.nodes.flatMap((node) => [
402
- ...node.dependencyIds.map((dependencyId) => ({ from: node.task.id, relation: "depends_on", to: dependencyId })),
403
- ...node.childIds.map((childId) => ({ from: node.task.id, relation: "contains", to: childId })),
404
- ]);
405
- return text(
406
- `Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`,
407
- createGraphDetails("tasks.graph", graph.nodes.map((node) => node.task), edges),
408
- );
409
- }
410
- if (action === "plan") {
411
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
412
- const byId = new Map(plan.nodes.map((node) => [node.id, node]));
413
- const titleCounts = new Map<string, number>();
414
- for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
415
- const nodeLabel = (id: string): string => {
416
- const node = byId.get(id);
417
- if (!node) return "unknown task";
418
- return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
419
- };
420
- const lines = plan.layers.flatMap((layer, index) => [
421
- `Layer ${index + 1}`,
422
- ...layer.map((id) => {
423
- const node = byId.get(id);
424
- return ` [${node?.state ?? "unknown"}] ${nodeLabel(id)}`;
425
- }),
426
- ]);
427
- if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
428
- const output = lines.join("\n") || "No tasks in execution plan.";
429
- return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
430
- }
431
- if (action === "context") {
432
- const summary = await callService<Record<string, unknown>, string | null>("tasks.context", { ...request, verbosity: "full" });
433
- const output = summary ?? "No open tasks.";
434
- return text(output, createPreviewDetails("tasks.context", "Task reconciliation context", output));
435
- }
436
- if (action === "set_checklist") {
437
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
438
- return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
439
- }
440
- if (action === "set_gates") {
441
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_gates", params);
442
- return text(`Updated gates: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_gates", artifact));
443
- }
444
- if (action === "complete") {
445
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
446
- const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
447
- const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
448
- const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
449
- const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
450
- const dependencyLabels = await artifactLabelsById(result.blocked.flatMap((entry) => entry.dependencyIds));
451
- const blocked = result.blocked.length > 0
452
- ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.map((id) => dependencyLabels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
453
- : "";
454
- const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
455
- return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
456
- }
457
- if (action === "run_gates") {
458
- const [gates, task] = await Promise.all([
459
- callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request),
460
- callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.id }),
461
- ]);
462
- return text(
463
- gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
464
- createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", task.title, gates.map((gate) => ({
465
- passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
466
- }))),
467
- );
468
- }
469
- if (action === "event_feed") {
470
- 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 });
471
- const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
472
- return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
473
- }
474
- if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
475
- const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
476
- if (action === "release_lease") {
477
- const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
478
- const output = released.released ? "Lease released." : "No live lease to release.";
479
- return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
480
- }
481
- const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
482
- const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
483
- const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
484
- return text(output, createPreviewDetails(operation, "Task lease", output));
485
- }
486
- if (action === "cancel_subtree") {
487
- const outcome = await callService<Record<string, unknown>, { canceled: string[]; skipped: string[] }>("tasks.cancel_subtree", request);
488
- const output = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
489
- return text(output, createPreviewDetails("tasks.cancel_subtree", "Cancel task subtree", JSON.stringify(outcome, null, 2)));
490
- }
491
- const trashResult = await handleArtifactRemoveRestore(action, params);
492
- if (trashResult) return trashResult;
493
- const operations = {
494
- focus: "tasks.focus",
495
- start: "tasks.start",
496
- submit: "tasks.submit",
497
- reject: "tasks.reject",
498
- retry: "tasks.retry",
499
- cancel: "tasks.cancel",
500
- update: "tasks.update",
501
- set_scope: "tasks.set_scope",
502
- assign_project: "tasks.assign_project",
503
- depend: "tasks.depend",
504
- undepend: "tasks.undepend",
505
- contain: "tasks.contain",
506
- uncontain: "tasks.uncontain",
507
- } as const;
508
- const operation = operations[action as keyof typeof operations];
509
- if (!operation) throw new Error(`unknown tasks action: ${action}`);
510
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
511
- if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
512
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
513
- })();
514
- if (notes.length > 0 && result.content[0]?.type === "text") result.content[0].text += `\n\n${notes.join("\n")}`;
515
- return result;
516
- } catch (error) {
517
- throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
518
- }
519
- },
520
- });
521
- }
522
-
523
- // notes.*, rules.*, docs.*, skills.*, playbooks.*, and the shared artifact.* are
237
+ // notes.*, rules.*, docs.*, playbooks.*, tasks.*, and the shared artifact.* are
524
238
  // registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
525
239
  // src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
526
240
 
@@ -627,11 +341,11 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
627
341
  }
628
342
 
629
343
  /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
630
- // notes, rules, docs, skills, and playbooks are no longer registered here -- all migrated onto
631
- // Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in index.ts),
632
- // replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
633
- // src/vehicle/papyrus-vehicle.ts for the server side.
344
+ // notes, rules, docs, 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.
634
349
  export function registerDomainTools(pi: ExtensionAPI): void {
635
- registerTasksTool(pi);
636
350
  registerDiscussTool(pi);
637
351
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-papyrus — native Pi extension for the Papyrus graph store.
3
3
  *
4
- * Tools: papyrus_query/graph/show (low-level), plus one native tool per domain (docs/rules/skills/playbooks/tasks/discuss/notes).
4
+ * Tools: papyrus_query/graph/show (low-level), plus one native tool per domain (docs/rules/playbooks/tasks/discuss/notes).
5
5
  * Command: /tasks (interactive task panel).
6
6
  * Widget: persistent task status above editor (rpiv-todo pattern).
7
7
  * Injection: active rules + open tasks appended to system prompt every turn.
@@ -327,7 +327,7 @@ export default async function (pi: ExtensionAPI) {
327
327
  const contextInjectionProducerId = randomUUID();
328
328
  let previousContextInjectionFingerprint: string | undefined;
329
329
  let logTurnSequence = 0;
330
- // Papyrus's own Context Hub contribution (rules/tasks/skills, bundled into one segment --
330
+ // Papyrus's own Context Hub contribution (rules/tasks/Pi's own skill catalog, bundled into one segment --
331
331
  // see context-hub-contribution.ts) re-emits every turn alongside the existing injection
332
332
  // observation, its own independent monotonic sequence, same cadence and shape as
333
333
  // contextInjectionSequence but on a different channel/schema.
@@ -438,7 +438,7 @@ export default async function (pi: ExtensionAPI) {
438
438
  "ACTIONS: link (from+relation+to), unlink (from+relation+to — idempotent, no error if already absent; for Task depends_on/contains prefer the tasks tool's undepend/uncontain), " +
439
439
  "tree (id → bounded BFS subgraph), " +
440
440
  "history (who did what, when — requires id, actor, or session_id). " +
441
- "status (id+status) exists at the protocol level but is refused for every kind with its own lifecycle (Doc/Rule/Skill/Playbook/Task/Note all reject it) -- use that kind's own domain tool for status changes (docs.activate, rules.enable, tasks.start, etc), never this. " +
441
+ "status (id+status) exists at the protocol level but is refused for every kind with its own lifecycle (Doc/Rule/Playbook/Task/Note all reject it) -- use that kind's own domain tool for status changes (docs.activate, rules.enable, tasks.start, etc), never this. " +
442
442
  "PREFER `from_name`/`to_name` over `from`/`to` for link/unlink -- both are backend implementation details, resolved from name automatically, searching across every kind since either end of an edge can be any artifact.",
443
443
  parameters: Type.Object({
444
444
  action: Type.String({ description: "link | unlink | tree | status | history" }),
@@ -462,7 +462,7 @@ export default async function (pi: ExtensionAPI) {
462
462
  try {
463
463
  const params: Record<string, unknown> = { ...rawParams };
464
464
  if (params.action === "link" || params.action === "unlink") {
465
- // Kind-agnostic: either end of an edge can be a task, doc, rule, skill, or playbook.
465
+ // Kind-agnostic: either end of an edge can be a task, doc, rule, or playbook.
466
466
  await resolveNameFields(params, [
467
467
  { nameKey: "from_name", idKey: "from", listOperation: "artifact.query", baseRequest: {} },
468
468
  { nameKey: "to_name", idKey: "to", listOperation: "artifact.query", baseRequest: {} },
@@ -563,12 +563,11 @@ export default async function (pi: ExtensionAPI) {
563
563
  // ── Interactive artifact browsers ──────────────────────────────────
564
564
 
565
565
  // Lazy imports keep TUI components out of non-interactive startup paths.
566
- const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, playbooksModule, discussModule] = await Promise.all([
566
+ const [tasksModule, docsModule, notesModule, rulesModule, playbooksModule, discussModule] = await Promise.all([
567
567
  import("./tasks.ts"),
568
568
  import("./docs.ts"),
569
569
  import("./notes.ts"),
570
570
  import("./rules.ts"),
571
- import("./skills.ts"),
572
571
  import("./playbooks.ts"),
573
572
  import("./discuss.ts"),
574
573
  ]);
@@ -607,10 +606,6 @@ export default async function (pi: ExtensionAPI) {
607
606
  description: "Browse, preview, and toggle Papyrus rules (interactive)",
608
607
  handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
609
608
  });
610
- pi.registerCommand("skills", {
611
- description: "Browse and invoke Papyrus skills and templates (interactive)",
612
- handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
613
- });
614
609
  pi.registerCommand("playbooks", {
615
610
  description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
616
611
  handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, skills.*,
3
- * playbooks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
2
+ * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, playbooks.*,
3
+ * tasks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
4
4
  * src/vehicle/papyrus-vehicle.ts.
5
5
  *
6
6
  * Fails silently on a stale/unreachable daemon handle instead of aborting extension
@@ -16,12 +16,17 @@ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
16
16
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
17
17
  import { currentVehicleClientTarget } from "./service-client.ts";
18
18
  import { sessionSecretField } from "./session-identity.ts";
19
+ import { emitTaskFocusEvent } from "./task-focus-events.ts";
19
20
 
20
21
  const REGISTERED_PERMISSIONS = [
21
22
  "notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write",
22
- "skills:read", "skills:write", "playbooks:read", "playbooks:write", "artifact:read", "artifact:write",
23
+ "playbooks:read", "playbooks:write", "tasks:read", "tasks:write",
24
+ "artifact:read", "artifact:write",
23
25
  ];
24
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"]);
29
+
25
30
  export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
26
31
  const target = currentVehicleClientTarget();
27
32
  if (!target) return;
@@ -30,17 +35,24 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
30
35
  await registerVehicleTools(pi, client, {
31
36
  permissions: REGISTERED_PERMISSIONS,
32
37
  principal: { id: "pi-papyrus" },
33
- // playbooks.invoke's own module handler authorizes an internal Task Focus write via
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
34
40
  // sessionIdentity.assertAuthorized(session_id, session_secret) -- see
35
- // @danypops/papyrus's src/vehicle/playbooks-vehicle.ts. That secret must never be a
36
- // model-visible input field (the model has no business knowing or supplying it), so
37
- // it travels here instead, in principal.claims, from this extension's own already-
38
- // cached secret (registered at session_start -- see index.ts) -- the same value
39
- // sessionSecretField() used to thread through as a raw RPC input field before this
40
- // operation moved onto Vehicle.
41
- resolveInvocation: ({ descriptor, context }) => {
42
- if (descriptor.name !== "playbooks.invoke") return {};
43
- const sessionId = context.sessionManager.getSessionId();
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();
44
56
  const { session_secret: sessionSecret } = sessionSecretField(sessionId);
45
57
  // Omit sessionSecret entirely when nothing is cached (unregistered session) --
46
58
  // {sessionSecret: null} would fail the module's own optionalString(input,
@@ -49,6 +61,26 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
49
61
  const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
50
62
  return { principal: { id: "pi-papyrus", claims } };
51
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
+ },
52
84
  });
53
85
  } catch {
54
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.41.0",
3
+ "version": "0.43.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,13 +18,13 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/jittor": "^0.14.0",
21
- "@danypops/papyrus": "^0.40.0",
21
+ "@danypops/papyrus": "^0.42.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
- "malevich-tui-components": "^0.5.0"
27
+ "malevich-tui-components": "^0.16.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@earendil-works/pi-coding-agent": "^0.80.10",
@@ -1,125 +0,0 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import type { Artifact, WorkflowRunResult, TaskGraph } from "@danypops/papyrus";
3
- import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
- import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
5
- import { callService } from "./service-client.ts";
6
- import { showTaskGraph } from "./task-graph.ts";
7
-
8
- function strings(value: unknown): string[] {
9
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
10
- }
11
-
12
- export function skillRowMeta(skill: Artifact): string {
13
- if (skill.subtype === "artifact-template") {
14
- const target = typeof skill.extra["targetKind"] === "string" ? skill.extra["targetKind"] : "artifact";
15
- return `template → ${target}`;
16
- }
17
- if (skill.subtype === "workflow") {
18
- const definition = skill.extra["definition"] as Record<string, unknown> | undefined;
19
- const inputs = definition?.["inputs"] && typeof definition["inputs"] === "object"
20
- ? Object.keys(definition["inputs"] as Record<string, unknown>).length
21
- : 0;
22
- const blueprints = definition?.["blueprints"] as Record<string, unknown> | undefined;
23
- const tasks = Array.isArray(blueprints?.["tasks"]) ? blueprints["tasks"].length : 0;
24
- return `workflow · ${inputs} inputs · ${tasks} tasks`;
25
- }
26
- const trigger = typeof skill.extra["trigger"] === "string" ? `when ${skill.extra["trigger"]}` : "manual";
27
- const tools = strings(skill.extra["tools"]);
28
- return [trigger, tools.join(", ")].filter(Boolean).join(" · ");
29
- }
30
-
31
- export function skillInvocationPrompt(skill: Artifact): string {
32
- if (skill.subtype === "artifact-template") {
33
- return [`Create an artifact using Papyrus template \"${skill.title}\".`, `template_name: ${skill.title}`, "Ask for or infer the title and all required template fields, then call the skills domain tool with action=instantiate."].join("\n");
34
- }
35
- if (skill.subtype === "workflow") {
36
- return [
37
- `Run Papyrus workflow Skill \"${skill.title}\".`,
38
- "Collect its required arguments, then call the skills domain tool with action=run.",
39
- ].join("\n");
40
- }
41
- const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
42
- const steps = strings(skill.extra["steps"]);
43
- const tools = strings(skill.extra["tools"]);
44
- return [
45
- `Apply Papyrus skill \"${skill.title}\".`,
46
- `Trigger: ${trigger}`,
47
- ...(skill.body ? [`Context: ${skill.body}`] : []),
48
- ...(steps.length > 0 ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
49
- ...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`] : []),
50
- ].join("\n");
51
- }
52
-
53
- export function skillRunTaskGraph(run: WorkflowRunResult, taskArtifacts: Artifact[]): TaskGraph {
54
- const executionById = new Map(run.execution.nodes.map((node) => [node.id, node]));
55
- return {
56
- nodes: taskArtifacts.map((task) => ({
57
- task,
58
- active: executionById.get(task.id)?.active === true,
59
- parentIds: [],
60
- childIds: [],
61
- dependencyIds: executionById.get(task.id)?.prerequisiteIds ?? [],
62
- })),
63
- rootIds: run.rootTaskIds,
64
- };
65
- }
66
-
67
- export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
68
- await showArtifactBrowser(ctx, {
69
- kind: "skill",
70
- title: "Skills",
71
- listOperation: "skills.list",
72
- statusOrder: ["active", "deprecated"],
73
- presentation: SKILL_STATUS_PRESENTATION,
74
- rowMeta: skillRowMeta,
75
- actions: (skill) => [
76
- "Show details",
77
- "Edit",
78
- skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
79
- skill.status === "active" ? "Disable" : "Enable",
80
- ],
81
- handleAction: async (choice, skill, commandCtx) => {
82
- if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
83
- else if (choice === "Edit") {
84
- const title = await commandCtx.ui.input("Title:", skill.title);
85
- if (title === undefined) return; // canceled
86
- const body = await commandCtx.ui.input("Body:", skill.body);
87
- if (body === undefined) return; // canceled
88
- const updated = await callService<Record<string, unknown>, Artifact>("skills.update", { id: skill.id, title, body });
89
- commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
90
- } else if (choice === "Run workflow") {
91
- const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
92
- if (source === undefined) return;
93
- try {
94
- const arguments_ = JSON.parse(source) as unknown;
95
- if (typeof arguments_ !== "object" || arguments_ === null || Array.isArray(arguments_)) {
96
- throw new Error("arguments must be a JSON object");
97
- }
98
- const run = await callService<Record<string, unknown>, WorkflowRunResult>("skills.run", {
99
- id: skill.id,
100
- arguments: arguments_ as Record<string, unknown>,
101
- project_root: commandCtx.cwd,
102
- });
103
- commandCtx.ui.notify([
104
- `Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
105
- `Context docs: ${run.created.docs.join(", ") || "none"}`,
106
- `Scoped rules: ${run.created.rules.join(", ") || "none"}`,
107
- ].join("\n"), "info");
108
- const taskArtifacts = await Promise.all(run.execution.nodes.map((node) =>
109
- callService<Record<string, unknown>, Artifact>("tasks.show", { id: node.id })));
110
- await showTaskGraph(commandCtx, skillRunTaskGraph(run, taskArtifacts));
111
- } catch (error) {
112
- commandCtx.ui.notify(`Workflow run failed: ${error instanceof Error ? error.message : error}`, "error");
113
- }
114
- } else if (choice === "Invoke skill" || choice === "Use template") {
115
- const invocation = await callService<Record<string, unknown>, string>("skills.invoke", { id: skill.id });
116
- commandCtx.ui.setEditorText(invocation);
117
- commandCtx.ui.notify("Invocation placed in the editor", "info");
118
- } else {
119
- const operation = choice === "Disable" ? "skills.disable" : "skills.enable";
120
- const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: skill.id });
121
- commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
122
- }
123
- },
124
- });
125
- }