@danypops/papyrus 0.21.2 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,6 +127,10 @@ Every tool operation is registered in the daemon’s `/api/v1/ops` registry; par
127
127
 
128
128
  Internally, application services depend on the `ArtifactStore` and `GateRunner` ports. SQLite and subprocess execution are adapters composed only by the daemon; task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs and sends them through a `GraphRenderer` port; the Pi adapter uses `beautiful-mermaid` for terminal Unicode output without leaking Mermaid syntax into the task domain.
129
129
 
130
+ ### Naming vs. ids
131
+
132
+ Every agent domain tool (tasks, docs, rules, skills, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), `template_name` (skills instantiate), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
133
+
130
134
  ## Interactive frontends
131
135
 
132
136
  - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
@@ -179,8 +183,6 @@ papyrus discuss show <discussion-id> --json
179
183
 
180
184
  ## Tasks
181
185
 
182
- The `tasks` agent tool addresses a task by `name` (its exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Task results returned to the agent likewise lead with name and status, never id, unless two tasks in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action.
183
-
184
186
  Run `/tasks` for the interactive task panel:
185
187
 
186
188
  - `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
@@ -9,6 +9,7 @@ import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
11
11
  import type { DiscussionRound } from "../../src/domain/discussion.ts";
12
+ import type { OperationName } from "../../src/service.ts";
12
13
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
13
14
  import { sessionSecretField } from "./session-identity.ts";
14
15
  import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
@@ -29,27 +30,22 @@ function text(message: string, details: unknown = {}) {
29
30
  return { content: [{ type: "text" as const, text: modelContent.text }], details };
30
31
  }
31
32
 
32
- function artifactLine(artifact: Artifact): string {
33
- return `${artifact.id} [${artifact.status}] ${artifact.title}`;
34
- }
35
-
36
33
  /**
37
- * Tasks-only: the model's primary interfacing point is the task's NAME, not its id -- id is a
38
- * backend detail (a stable key other operations need, and titles aren't guaranteed unique), so
39
- * it stays out of what the model reads by default. It only resurfaces when genuinely needed to
40
- * tell two same-titled tasks apart (taskLines below), or in a matchTaskByName disambiguation
41
- * error, never as a matter of course. This is scoped to the tasks tool specifically -- Docs/
42
- * Rules/Skills/Discuss keep the shared artifactLine above unless a similar request covers them.
34
+ * Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
35
+ * backend implementation detail (a stable key other operations need, and titles aren't
36
+ * guaranteed unique), so it stays out of what the model reads by default. It only resurfaces
37
+ * when genuinely needed to tell two same-titled artifacts apart (artifactLines below), or in a
38
+ * matchArtifactByName disambiguation error, never as a matter of course.
43
39
  */
44
- export function taskLine(task: Artifact): string {
45
- return `[${task.status}] ${task.title}`;
40
+ export function artifactLine(artifact: Artifact): string {
41
+ return `[${artifact.status}] ${artifact.title}`;
46
42
  }
47
43
 
48
- /** Appends " (id)" only for tasks whose title collides with another in this same result set. */
49
- export function taskLines(tasks: Artifact[]): string[] {
44
+ /** Appends " (id)" only for artifacts whose title collides with another in this same result set. */
45
+ export function artifactLines(artifacts: Artifact[]): string[] {
50
46
  const titleCounts = new Map<string, number>();
51
- for (const task of tasks) titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
52
- return tasks.map((task) => (titleCounts.get(task.title)! > 1 ? `${taskLine(task)} (${task.id})` : taskLine(task)));
47
+ for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
48
+ return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
53
49
  }
54
50
 
55
51
  /**
@@ -58,20 +54,53 @@ export function taskLines(tasks: Artifact[]): string[] {
58
54
  * remains the one truly unambiguous key, so ambiguity is exactly where it's allowed to resurface.
59
55
  * Pure and synchronous so it's directly testable without a service round-trip.
60
56
  */
61
- export function matchTaskByName(candidates: Artifact[], name: string): string {
57
+ export function matchArtifactByName(candidates: Artifact[], name: string): string {
62
58
  const needle = name.trim().toLowerCase();
63
- const matches = candidates.filter((task) => task.title.trim().toLowerCase() === needle);
64
- if (matches.length === 0) throw new Error(`no task named "${name}" found in this scope`);
59
+ const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
60
+ if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
65
61
  if (matches.length > 1) {
66
- throw new Error(`${matches.length} tasks are named "${name}": ${matches.map((task) => `${task.title} (${task.id})`).join(", ")} -- use id to disambiguate`);
62
+ throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((artifact) => `${artifact.title} (${artifact.id})`).join(", ")} -- use id to disambiguate`);
67
63
  }
68
64
  return matches[0]!.id;
69
65
  }
70
66
 
71
- /** Resolves a task name to its id, scoped the same way a plain `tasks list` call would be (same project_root/session_id/scope). */
72
- async function resolveTaskIdByName(baseRequest: Record<string, unknown>, name: string): Promise<string> {
73
- const candidates = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { ...baseRequest, text: name });
74
- return matchTaskByName(candidates, name);
67
+ /**
68
+ * Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
69
+ * scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
70
+ * kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
71
+ * should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
72
+ * uses, so resolution never searches a wider or narrower scope than a plain list call would.
73
+ */
74
+ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string): Promise<string> {
75
+ const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
76
+ return matchArtifactByName(candidates, name);
77
+ }
78
+
79
+ /** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
80
+ async function resolveNameFields(
81
+ params: Record<string, unknown>,
82
+ fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
83
+ ): Promise<void> {
84
+ for (const { nameKey, idKey, listOperation, baseRequest } of fields) {
85
+ const nameValue = params[nameKey];
86
+ if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
87
+ params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue);
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Resolves a `namesKey` string array to an `idsKey` id array, only when idsKey isn't already explicitly given. */
93
+ async function resolveNameArrayField(
94
+ params: Record<string, unknown>,
95
+ namesKey: string,
96
+ idsKey: string,
97
+ listOperation: OperationName,
98
+ baseRequest: Record<string, unknown>,
99
+ ): Promise<void> {
100
+ const names = params[namesKey];
101
+ if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
102
+ params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry))));
103
+ }
75
104
  }
76
105
 
77
106
  /**
@@ -82,13 +111,27 @@ async function resolveTaskIdByName(baseRequest: Record<string, unknown>, name: s
82
111
  * Returns null when action is neither, so callers fall through to their own dispatch.
83
112
  */
84
113
  async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
114
+ // Trashed/restored are still directly showable by id (see artifact-trash.ts), so the title is
115
+ // available either side of the action -- fetched here purely for a name-primary message; falls
116
+ // back to the raw id only if the artifact genuinely can't be shown (e.g. an unknown id).
117
+ const titleOf = async (): Promise<string> => {
118
+ try {
119
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params["id"] });
120
+ return artifact ? `"${artifact.title}"` : String(params["id"]);
121
+ } catch {
122
+ return String(params["id"]);
123
+ }
124
+ };
85
125
  if (action === "remove") {
126
+ const label = await titleOf();
86
127
  const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
87
- return text(`Trashed ${record.artifactId}, eligible for purge at ${record.purgeAfter}.`, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
128
+ const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
129
+ return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
88
130
  }
89
131
  if (action === "restore") {
132
+ const label = await titleOf();
90
133
  const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
91
- const output = outcome.restored ? `Restored ${params["id"]}.` : `${params["id"]} was not trashed.`;
134
+ const output = outcome.restored ? `Restored ${label}.` : `${label} was not trashed.`;
92
135
  return text(output, createPreviewDetails("artifact.restore", "Restored", output));
93
136
  }
94
137
  return null;
@@ -157,33 +200,26 @@ export function registerDomainTools(pi: ExtensionAPI): void {
157
200
  // Resolves every *_name field to its *_id counterpart before dispatch, so every action
158
201
  // below can go on reading id/dependency_id/parent_id/child_id/root_task_id exactly as
159
202
  // before -- id-based calls are unaffected; name-based ones are transparently rewritten.
160
- const resolveField = async (nameKey: string, idKey: string) => {
161
- const nameValue = params[nameKey];
162
- if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
163
- params[idKey] = await resolveTaskIdByName(baseRequest, nameValue);
164
- }
165
- };
166
- await resolveField("name", "id");
167
- await resolveField("dependency_name", "dependency_id");
168
- await resolveField("parent_name", "parent_id");
169
- await resolveField("child_name", "child_id");
170
- await resolveField("root_task_name", "root_task_id");
171
- const dependsOnNames = params["depends_on_names"];
172
- if (Array.isArray(dependsOnNames) && dependsOnNames.length > 0 && !params["depends_on"]) {
173
- params["depends_on"] = await Promise.all(dependsOnNames.map((entry) => resolveTaskIdByName(baseRequest, String(entry))));
174
- }
203
+ await resolveNameFields(params, [
204
+ { nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest },
205
+ { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest },
206
+ { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest },
207
+ { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest },
208
+ { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest },
209
+ ]);
210
+ await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", baseRequest);
175
211
  const request = { ...params, ...baseRequest };
176
212
  if (action === "create") {
177
213
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
178
- return text(`Created task ${taskLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
214
+ return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
179
215
  }
180
216
  if (action === "list") {
181
217
  const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
182
- return text(rows.length ? taskLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
218
+ return text(rows.length ? artifactLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
183
219
  }
184
220
  if (action === "show") {
185
221
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
186
- return text(`${taskLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
222
+ return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
187
223
  }
188
224
  if (action === "history") {
189
225
  const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
@@ -198,20 +234,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
198
234
  if (action === "active") {
199
235
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
200
236
  return artifact
201
- ? text(`Active: ${taskLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
237
+ ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
202
238
  : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
203
239
  }
204
240
  if (action === "focused") {
205
241
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
206
242
  return focus
207
- ? text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
243
+ ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
208
244
  : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
209
245
  }
210
246
  if (action === "pause" || action === "unpause") {
211
247
  const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
212
248
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
213
249
  emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
214
- return text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
250
+ return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
215
251
  }
216
252
  if (action === "clear_focus") {
217
253
  const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
@@ -253,18 +289,18 @@ export function registerDomainTools(pi: ExtensionAPI): void {
253
289
  }
254
290
  if (action === "set_checklist") {
255
291
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
256
- return text(`Updated checklist: ${taskLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
292
+ return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
257
293
  }
258
294
  if (action === "complete") {
259
295
  const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
260
296
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
261
297
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
262
- const focused = result.focused ? `\nActive: ${taskLine(result.focused)}` : "";
263
- const blockedLines = taskLines(result.blocked.map((entry) => entry.artifact));
298
+ const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
299
+ const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
264
300
  const blocked = result.blocked.length > 0
265
301
  ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
266
302
  : "";
267
- const output = `${result.completed ? "Completed" : "Rejected"}: ${taskLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
303
+ const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
268
304
  return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
269
305
  }
270
306
  if (action === "run_gates") {
@@ -297,7 +333,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
297
333
  if (!operation) throw new Error(`unknown tasks action: ${action}`);
298
334
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
299
335
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
300
- return text(taskLine(artifact), createArtifactDetails(operation, artifact));
336
+ return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
301
337
  } catch (error) {
302
338
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
303
339
  }
@@ -307,10 +343,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
307
343
  pi.registerTool({
308
344
  name: "notes",
309
345
  label: "Notes",
310
- description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id. Archive requires an explicit disposition.",
346
+ description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id. Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive -- id is a backend implementation detail, resolved from name automatically.",
311
347
  parameters: Type.Object({
312
348
  action: Type.String(),
313
349
  id: Type.Optional(Type.String()),
350
+ name: Type.Optional(Type.String()),
314
351
  body: Type.Optional(Type.String()),
315
352
  title: Type.Optional(Type.String()),
316
353
  status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
@@ -324,17 +361,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
324
361
  }),
325
362
  renderCall(args, theme) { return renderPapyrusToolCall("Notes", args, theme); },
326
363
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
327
- async execute(_id, params, _signal, _onUpdate, ctx) {
364
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
328
365
  try {
366
+ const params: Record<string, unknown> = { ...rawParams };
329
367
  const action = params.action;
330
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
368
+ const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
369
+ await resolveNameFields(params, [{ nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest }]);
370
+ const request = { ...params, ...baseRequest };
331
371
  if (action === "capture") {
332
372
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
333
373
  return text(`Captured note ${artifactLine(artifact)}`, createArtifactDetails("notes.capture", artifact));
334
374
  }
335
375
  if (action === "list") {
336
376
  const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", request);
337
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
377
+ return text(rows.length ? artifactLines(rows).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
338
378
  }
339
379
  if (action === "show") {
340
380
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
@@ -354,10 +394,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
354
394
  pi.registerTool({
355
395
  name: "docs",
356
396
  label: "Documents",
357
- description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. Prefer this over low-level papyrus_* tools for document work.",
397
+ description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
358
398
  parameters: Type.Object({
359
399
  action: Type.String(),
360
400
  id: Type.Optional(Type.String()),
401
+ name: Type.Optional(Type.String()),
361
402
  title: Type.Optional(Type.String()),
362
403
  body: Type.Optional(Type.String()),
363
404
  subtype: Type.Optional(Type.String()),
@@ -369,21 +410,29 @@ export function registerDomainTools(pi: ExtensionAPI): void {
369
410
  template_id: Type.Optional(Type.String()),
370
411
  relation: Type.Optional(Type.String()),
371
412
  target_id: Type.Optional(Type.String()),
413
+ target_name: Type.Optional(Type.String()),
372
414
  project_root: Type.Optional(Type.String()),
373
415
  reason: Type.Optional(Type.String()),
374
416
  }),
375
417
  renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
376
418
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
377
- async execute(_id, params) {
419
+ async execute(_id, rawParams) {
378
420
  try {
421
+ const params: Record<string, unknown> = { ...rawParams };
379
422
  const action = params.action;
423
+ const scopeRequest = { project_root: params.project_root };
424
+ await resolveNameFields(params, [
425
+ { nameKey: "name", idKey: "id", listOperation: "docs.list", baseRequest: scopeRequest },
426
+ // Kind-agnostic: a link target can be a doc, task, rule, or skill, so this searches every kind rather than only docs.
427
+ { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest: scopeRequest },
428
+ ]);
380
429
  if (action === "create") {
381
430
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.create", params);
382
431
  return text(`Created document ${artifactLine(artifact)}`, createArtifactDetails("docs.create", artifact));
383
432
  }
384
433
  if (action === "list") {
385
434
  const rows = await callService<Record<string, unknown>, Artifact[]>("docs.list", params);
386
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No documents found.", createArtifactListDetails("docs.list", rows));
435
+ return text(rows.length ? artifactLines(rows).join("\n") : "No documents found.", createArtifactListDetails("docs.list", rows));
387
436
  }
388
437
  if (action === "show") {
389
438
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
@@ -405,27 +454,33 @@ export function registerDomainTools(pi: ExtensionAPI): void {
405
454
  pi.registerTool({
406
455
  name: "rules",
407
456
  label: "Rules",
408
- description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
457
+ description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
409
458
  parameters: Type.Object({
410
- action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
459
+ action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
411
460
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
412
461
  severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
413
462
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
414
463
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
464
+ task_name: Type.Optional(Type.String()),
415
465
  project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
416
466
  }),
417
467
  renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
418
468
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
419
- async execute(_id, params) {
469
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
420
470
  try {
471
+ const params: Record<string, unknown> = { ...rawParams };
421
472
  const action = params.action;
473
+ await resolveNameFields(params, [
474
+ { nameKey: "name", idKey: "id", listOperation: "rules.list", baseRequest: { project_root: params.project_root } },
475
+ { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: { project_root: params.project_root ?? ctx.cwd } },
476
+ ]);
422
477
  if (action === "create") {
423
478
  const artifact = await callService<Record<string, unknown>, Artifact>("rules.create", params);
424
479
  return text(`Created rule ${artifactLine(artifact)}`, createArtifactDetails("rules.create", artifact));
425
480
  }
426
481
  if (action === "list") {
427
482
  const rows = await callService<Record<string, unknown>, Artifact[]>("rules.list", params);
428
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No rules found.", createArtifactListDetails("rules.list", rows));
483
+ return text(rows.length ? artifactLines(rows).join("\n") : "No rules found.", createArtifactListDetails("rules.list", rows));
429
484
  }
430
485
  if (action === "preview") {
431
486
  const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
@@ -447,25 +502,31 @@ export function registerDomainTools(pi: ExtensionAPI): void {
447
502
  pi.registerTool({
448
503
  name: "skills",
449
504
  label: "Skills",
450
- description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
505
+ description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
451
506
  parameters: Type.Object({
452
- action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
507
+ action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
453
508
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
454
509
  tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
455
510
  arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
456
511
  labels: Type.Optional(Type.Array(Type.String())),
457
512
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
458
513
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
514
+ template_name: Type.Optional(Type.String()),
459
515
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
460
516
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
461
517
  project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
462
518
  }),
463
519
  renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
464
520
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
465
- async execute(_id, params, _signal, _onUpdate, ctx) {
521
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
466
522
  try {
523
+ const params: Record<string, unknown> = { ...rawParams };
467
524
  const action = params.action;
468
525
  const request = { ...params, project_root: params.project_root ?? ctx.cwd };
526
+ await resolveNameFields(params, [
527
+ { nameKey: "name", idKey: "id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
528
+ { nameKey: "template_name", idKey: "template_id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
529
+ ]);
469
530
  if (action === "create" || action === "create_template") {
470
531
  const operation = action === "create" ? "skills.create" : "skills.create_template";
471
532
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -473,7 +534,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
473
534
  }
474
535
  if (action === "list") {
475
536
  const rows = await callService<Record<string, unknown>, Artifact[]>("skills.list", params);
476
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
537
+ return text(rows.length ? artifactLines(rows).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
477
538
  }
478
539
  if (action === "invoke") {
479
540
  const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
@@ -481,10 +542,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
481
542
  }
482
543
  if (action === "run") {
483
544
  const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
484
- const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
545
+ const runTitleCounts = new Map<string, number>();
546
+ for (const node of run.execution.nodes) runTitleCounts.set(node.title, (runTitleCounts.get(node.title) ?? 0) + 1);
547
+ const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
548
+ ? ` [${node.state}] ${node.title} (${node.id})`
549
+ : ` [${node.state}] ${node.title}`).join("\n");
550
+ // Root task titles are free here (already present in execution.nodes); created docs/rules
551
+ // are a different kind not covered by this run's own execution nodes, so those still list by
552
+ // id below -- fetching their titles would mean an extra round-trip per artifact.
553
+ const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
554
+ const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? id);
485
555
  return text([
486
556
  `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
487
- `Ready roots: ${run.rootTaskIds.join(", ") || "none"}.`,
557
+ `Ready roots: ${rootLabels.join(", ") || "none"}.`,
488
558
  `Context docs: ${run.created.docs.join(", ") || "none"}.`,
489
559
  `Scoped rules: ${run.created.rules.join(", ") || "none"}.`,
490
560
  ...(execution ? ["Execution:", execution] : []),
@@ -511,17 +581,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
511
581
  pi.registerTool({
512
582
  name: "discuss",
513
583
  label: "Discuss",
514
- description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it.",
584
+ description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
515
585
  parameters: Type.Object({
516
586
  action: Type.String(),
517
587
  id: Type.Optional(Type.String()),
588
+ name: Type.Optional(Type.String()),
518
589
  title: Type.Optional(Type.String()),
519
590
  actor: Type.Optional(Type.String()),
520
591
  content: Type.Optional(Type.String()),
521
592
  body: Type.Optional(Type.String()),
522
593
  labels: Type.Optional(Type.Array(Type.String())),
523
594
  blocks_task_ids: Type.Optional(Type.Array(Type.String())),
595
+ blocks_task_names: Type.Optional(Type.Array(Type.String())),
524
596
  task_id: Type.Optional(Type.String()),
597
+ task_name: Type.Optional(Type.String()),
525
598
  reason: Type.Optional(Type.String()),
526
599
  settlement: Type.Optional(Type.String()),
527
600
  state: Type.Optional(Type.String()),
@@ -533,26 +606,36 @@ export function registerDomainTools(pi: ExtensionAPI): void {
533
606
  }),
534
607
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
535
608
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
536
- async execute(_id, params) {
609
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
537
610
  try {
611
+ const params: Record<string, unknown> = { ...rawParams };
538
612
  const action = params.action;
613
+ const taskScope = { project_root: ctx.cwd };
614
+ await resolveNameFields(params, [
615
+ { nameKey: "name", idKey: "id", listOperation: "discuss.list", baseRequest: {} },
616
+ { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
617
+ ]);
618
+ await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
539
619
  if (action === "open") {
540
620
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.open", params);
541
621
  return text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion));
542
622
  }
543
623
  if (action === "reply") {
544
624
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", params);
545
- return text(`Round ${result.rounds[0]?.roundNumber} added to ${result.discussion.id}`, createArtifactDetails("discuss.reply", result.discussion));
546
- }
547
- if (action === "block") {
548
- await callService<Record<string, unknown>, { blocked: boolean }>("discuss.block", params);
549
- const message = `${params.id} now blocks ${params.task_id}`;
550
- return text(message, createPreviewDetails("discuss.block", "Blocked", message));
551
- }
552
- if (action === "unblock") {
553
- const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", params);
554
- const message = result.unblocked ? `${params.id} no longer blocks ${params.task_id}` : "No such blocking relationship.";
555
- return text(message, createPreviewDetails("discuss.unblock", "Unblocked", message));
625
+ return text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
626
+ }
627
+ if (action === "block" || action === "unblock") {
628
+ const operation = action === "block" ? "discuss.block" : "discuss.unblock";
629
+ const [outcome, discussionAndRounds, task] = await Promise.all([
630
+ callService<Record<string, unknown>, { blocked?: boolean; unblocked?: boolean }>(operation, params),
631
+ callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: params.id }),
632
+ callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
633
+ ]);
634
+ const discussion = discussionAndRounds.discussion;
635
+ const message = action === "unblock" && !outcome.unblocked
636
+ ? "No such blocking relationship."
637
+ : `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
638
+ return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
556
639
  }
557
640
  if (action === "show") {
558
641
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
@@ -566,7 +649,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
566
649
  }
567
650
  if (action === "list") {
568
651
  const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
569
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
652
+ return text(rows.length ? artifactLines(rows).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
570
653
  }
571
654
  const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
572
655
  const operation = operations[action as keyof typeof operations];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],