@danypops/pi-papyrus 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/extension/src/domain-tools.ts +6 -292
- package/extension/src/vehicle-notes-client.ts +44 -12
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ 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`, `skills`, `playbooks`, and `tasks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, `tasks_complete`, and so on) -- no `action` dispatch, each with its own schema:
|
|
14
14
|
|
|
15
|
-
-
|
|
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
16
|
- **notes** (`notes_capture`, `notes_list`, `notes_show`, `notes_consume`, `notes_promote`, `notes_archive`) — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
|
|
17
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
|
|
@@ -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
|
|
@@ -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.*, skills.*, 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
|
|
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, skills, playbooks, and tasks are no longer registered here -- all migrated
|
|
345
|
+
// onto Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
|
|
346
|
+
// index.ts), replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
|
|
347
|
+
// src/vehicle/papyrus-vehicle.ts for the server side. discuss remains here -- live:true needs an
|
|
348
|
+
// interactive UI round-trip a stateless Vehicle operation can't express.
|
|
634
349
|
export function registerDomainTools(pi: ExtensionAPI): void {
|
|
635
|
-
registerTasksTool(pi);
|
|
636
350
|
registerDiscussTool(pi);
|
|
637
351
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, skills.*,
|
|
3
|
-
* playbooks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
|
|
3
|
+
* playbooks.*, 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", "
|
|
23
|
+
"skills:read", "skills:write", "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.42.0",
|
|
4
4
|
"description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@danypops/jittor": "^0.14.0",
|
|
21
|
-
"@danypops/papyrus": "^0.
|
|
21
|
+
"@danypops/papyrus": "^0.41.0",
|
|
22
22
|
"@danypops/vehicle-core": "^0.2.0",
|
|
23
23
|
"@danypops/vehicle-server": "^0.1.1",
|
|
24
24
|
"@danypops/vehicle-client": "^0.1.1",
|
|
25
|
-
"@danypops/vehicle-client-pi": "^0.
|
|
25
|
+
"@danypops/vehicle-client-pi": "^0.3.0",
|
|
26
26
|
"beautiful-mermaid": "1.1.3",
|
|
27
27
|
"malevich-tui-components": "^0.5.0"
|
|
28
28
|
},
|