@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 +11 -13
- package/extension/src/artifact-status-presentation.ts +2 -7
- package/extension/src/discuss.ts +1 -1
- package/extension/src/domain-tools.ts +9 -295
- package/extension/src/index.ts +5 -10
- package/extension/src/vehicle-notes-client.ts +45 -13
- package/package.json +4 -4
- package/extension/src/skills.ts +0 -125
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. `
|
|
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
|
-
-
|
|
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/
|
|
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
|
-
- **
|
|
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,
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
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#
|
|
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,
|
|
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" },
|
package/extension/src/discuss.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
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,
|
|
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/
|
|
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
|
-
|
|
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,
|
|
631
|
-
// Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
|
|
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
|
}
|
package/extension/src/index.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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,
|
|
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,
|
|
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.*,
|
|
3
|
-
*
|
|
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
|
-
"
|
|
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
|
|
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
|
|
36
|
-
// model-visible input field (the model has no business
|
|
37
|
-
// it travels here instead, in principal.claims, from
|
|
38
|
-
// cached secret (registered at session_start -- see
|
|
39
|
-
// sessionSecretField() used to thread through as a raw
|
|
40
|
-
//
|
|
41
|
-
resolveInvocation: ({ descriptor, context }) => {
|
|
42
|
-
if (descriptor.name !== "playbooks.invoke") return {};
|
|
43
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
"@danypops/vehicle-client-pi": "^0.3.0",
|
|
26
26
|
"beautiful-mermaid": "1.1.3",
|
|
27
|
-
"malevich-tui-components": "^0.
|
|
27
|
+
"malevich-tui-components": "^0.16.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@earendil-works/pi-coding-agent": "^0.80.10",
|
package/extension/src/skills.ts
DELETED
|
@@ -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
|
-
}
|