@danypops/papyrus 0.21.2 → 0.21.4

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,25 +289,28 @@ 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") {
271
- const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
307
+ const [gates, task] = await Promise.all([
308
+ callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request),
309
+ callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.id }),
310
+ ]);
272
311
  return text(
273
312
  gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
274
- createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", gates.map((gate) => ({
313
+ createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", task.title, gates.map((gate) => ({
275
314
  passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
276
315
  }))),
277
316
  );
@@ -297,7 +336,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
297
336
  if (!operation) throw new Error(`unknown tasks action: ${action}`);
298
337
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
299
338
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
300
- return text(taskLine(artifact), createArtifactDetails(operation, artifact));
339
+ return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
301
340
  } catch (error) {
302
341
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
303
342
  }
@@ -307,10 +346,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
307
346
  pi.registerTool({
308
347
  name: "notes",
309
348
  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.",
349
+ 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
350
  parameters: Type.Object({
312
351
  action: Type.String(),
313
352
  id: Type.Optional(Type.String()),
353
+ name: Type.Optional(Type.String()),
314
354
  body: Type.Optional(Type.String()),
315
355
  title: Type.Optional(Type.String()),
316
356
  status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
@@ -324,17 +364,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
324
364
  }),
325
365
  renderCall(args, theme) { return renderPapyrusToolCall("Notes", args, theme); },
326
366
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
327
- async execute(_id, params, _signal, _onUpdate, ctx) {
367
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
328
368
  try {
369
+ const params: Record<string, unknown> = { ...rawParams };
329
370
  const action = params.action;
330
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
371
+ const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
372
+ await resolveNameFields(params, [{ nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest }]);
373
+ const request = { ...params, ...baseRequest };
331
374
  if (action === "capture") {
332
375
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
333
376
  return text(`Captured note ${artifactLine(artifact)}`, createArtifactDetails("notes.capture", artifact));
334
377
  }
335
378
  if (action === "list") {
336
379
  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));
380
+ return text(rows.length ? artifactLines(rows).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
338
381
  }
339
382
  if (action === "show") {
340
383
  const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
@@ -354,10 +397,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
354
397
  pi.registerTool({
355
398
  name: "docs",
356
399
  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.",
400
+ 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
401
  parameters: Type.Object({
359
402
  action: Type.String(),
360
403
  id: Type.Optional(Type.String()),
404
+ name: Type.Optional(Type.String()),
361
405
  title: Type.Optional(Type.String()),
362
406
  body: Type.Optional(Type.String()),
363
407
  subtype: Type.Optional(Type.String()),
@@ -369,21 +413,29 @@ export function registerDomainTools(pi: ExtensionAPI): void {
369
413
  template_id: Type.Optional(Type.String()),
370
414
  relation: Type.Optional(Type.String()),
371
415
  target_id: Type.Optional(Type.String()),
416
+ target_name: Type.Optional(Type.String()),
372
417
  project_root: Type.Optional(Type.String()),
373
418
  reason: Type.Optional(Type.String()),
374
419
  }),
375
420
  renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
376
421
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
377
- async execute(_id, params) {
422
+ async execute(_id, rawParams) {
378
423
  try {
424
+ const params: Record<string, unknown> = { ...rawParams };
379
425
  const action = params.action;
426
+ const scopeRequest = { project_root: params.project_root };
427
+ await resolveNameFields(params, [
428
+ { nameKey: "name", idKey: "id", listOperation: "docs.list", baseRequest: scopeRequest },
429
+ // Kind-agnostic: a link target can be a doc, task, rule, or skill, so this searches every kind rather than only docs.
430
+ { nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest: scopeRequest },
431
+ ]);
380
432
  if (action === "create") {
381
433
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.create", params);
382
434
  return text(`Created document ${artifactLine(artifact)}`, createArtifactDetails("docs.create", artifact));
383
435
  }
384
436
  if (action === "list") {
385
437
  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));
438
+ return text(rows.length ? artifactLines(rows).join("\n") : "No documents found.", createArtifactListDetails("docs.list", rows));
387
439
  }
388
440
  if (action === "show") {
389
441
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
@@ -405,27 +457,33 @@ export function registerDomainTools(pi: ExtensionAPI): void {
405
457
  pi.registerTool({
406
458
  name: "rules",
407
459
  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.",
460
+ 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
461
  parameters: Type.Object({
410
- action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
462
+ action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
411
463
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
412
464
  severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
413
465
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
414
466
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
467
+ task_name: Type.Optional(Type.String()),
415
468
  project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
416
469
  }),
417
470
  renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
418
471
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
419
- async execute(_id, params) {
472
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
420
473
  try {
474
+ const params: Record<string, unknown> = { ...rawParams };
421
475
  const action = params.action;
476
+ await resolveNameFields(params, [
477
+ { nameKey: "name", idKey: "id", listOperation: "rules.list", baseRequest: { project_root: params.project_root } },
478
+ { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: { project_root: params.project_root ?? ctx.cwd } },
479
+ ]);
422
480
  if (action === "create") {
423
481
  const artifact = await callService<Record<string, unknown>, Artifact>("rules.create", params);
424
482
  return text(`Created rule ${artifactLine(artifact)}`, createArtifactDetails("rules.create", artifact));
425
483
  }
426
484
  if (action === "list") {
427
485
  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));
486
+ return text(rows.length ? artifactLines(rows).join("\n") : "No rules found.", createArtifactListDetails("rules.list", rows));
429
487
  }
430
488
  if (action === "preview") {
431
489
  const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
@@ -447,25 +505,31 @@ export function registerDomainTools(pi: ExtensionAPI): void {
447
505
  pi.registerTool({
448
506
  name: "skills",
449
507
  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.",
508
+ 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
509
  parameters: Type.Object({
452
- action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
510
+ action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
453
511
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
454
512
  tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
455
513
  arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
456
514
  labels: Type.Optional(Type.Array(Type.String())),
457
515
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
458
516
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
517
+ template_name: Type.Optional(Type.String()),
459
518
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
460
519
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
461
520
  project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
462
521
  }),
463
522
  renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
464
523
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
465
- async execute(_id, params, _signal, _onUpdate, ctx) {
524
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
466
525
  try {
526
+ const params: Record<string, unknown> = { ...rawParams };
467
527
  const action = params.action;
468
528
  const request = { ...params, project_root: params.project_root ?? ctx.cwd };
529
+ await resolveNameFields(params, [
530
+ { nameKey: "name", idKey: "id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
531
+ { nameKey: "template_name", idKey: "template_id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
532
+ ]);
469
533
  if (action === "create" || action === "create_template") {
470
534
  const operation = action === "create" ? "skills.create" : "skills.create_template";
471
535
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -473,7 +537,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
473
537
  }
474
538
  if (action === "list") {
475
539
  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));
540
+ return text(rows.length ? artifactLines(rows).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
477
541
  }
478
542
  if (action === "invoke") {
479
543
  const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
@@ -481,10 +545,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
481
545
  }
482
546
  if (action === "run") {
483
547
  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");
548
+ const runTitleCounts = new Map<string, number>();
549
+ for (const node of run.execution.nodes) runTitleCounts.set(node.title, (runTitleCounts.get(node.title) ?? 0) + 1);
550
+ const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
551
+ ? ` [${node.state}] ${node.title} (${node.id})`
552
+ : ` [${node.state}] ${node.title}`).join("\n");
553
+ // Root task titles are free here (already present in execution.nodes); created docs/rules
554
+ // are a different kind not covered by this run's own execution nodes, so those still list by
555
+ // id below -- fetching their titles would mean an extra round-trip per artifact.
556
+ const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
557
+ const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? id);
485
558
  return text([
486
559
  `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"}.`,
560
+ `Ready roots: ${rootLabels.join(", ") || "none"}.`,
488
561
  `Context docs: ${run.created.docs.join(", ") || "none"}.`,
489
562
  `Scoped rules: ${run.created.rules.join(", ") || "none"}.`,
490
563
  ...(execution ? ["Execution:", execution] : []),
@@ -511,17 +584,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
511
584
  pi.registerTool({
512
585
  name: "discuss",
513
586
  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.",
587
+ 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
588
  parameters: Type.Object({
516
589
  action: Type.String(),
517
590
  id: Type.Optional(Type.String()),
591
+ name: Type.Optional(Type.String()),
518
592
  title: Type.Optional(Type.String()),
519
593
  actor: Type.Optional(Type.String()),
520
594
  content: Type.Optional(Type.String()),
521
595
  body: Type.Optional(Type.String()),
522
596
  labels: Type.Optional(Type.Array(Type.String())),
523
597
  blocks_task_ids: Type.Optional(Type.Array(Type.String())),
598
+ blocks_task_names: Type.Optional(Type.Array(Type.String())),
524
599
  task_id: Type.Optional(Type.String()),
600
+ task_name: Type.Optional(Type.String()),
525
601
  reason: Type.Optional(Type.String()),
526
602
  settlement: Type.Optional(Type.String()),
527
603
  state: Type.Optional(Type.String()),
@@ -533,26 +609,36 @@ export function registerDomainTools(pi: ExtensionAPI): void {
533
609
  }),
534
610
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
535
611
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
536
- async execute(_id, params) {
612
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
537
613
  try {
614
+ const params: Record<string, unknown> = { ...rawParams };
538
615
  const action = params.action;
616
+ const taskScope = { project_root: ctx.cwd };
617
+ await resolveNameFields(params, [
618
+ { nameKey: "name", idKey: "id", listOperation: "discuss.list", baseRequest: {} },
619
+ { nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
620
+ ]);
621
+ await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
539
622
  if (action === "open") {
540
623
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.open", params);
541
624
  return text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion));
542
625
  }
543
626
  if (action === "reply") {
544
627
  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));
628
+ return text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
629
+ }
630
+ if (action === "block" || action === "unblock") {
631
+ const operation = action === "block" ? "discuss.block" : "discuss.unblock";
632
+ const [outcome, discussionAndRounds, task] = await Promise.all([
633
+ callService<Record<string, unknown>, { blocked?: boolean; unblocked?: boolean }>(operation, params),
634
+ callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: params.id }),
635
+ callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
636
+ ]);
637
+ const discussion = discussionAndRounds.discussion;
638
+ const message = action === "unblock" && !outcome.unblocked
639
+ ? "No such blocking relationship."
640
+ : `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
641
+ return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
556
642
  }
557
643
  if (action === "show") {
558
644
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
@@ -566,7 +652,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
566
652
  }
567
653
  if (action === "list") {
568
654
  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));
655
+ return text(rows.length ? artifactLines(rows).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
570
656
  }
571
657
  const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
572
658
  const operation = operations[action as keyof typeof operations];
@@ -16,7 +16,9 @@ export interface PapyrusToolRenderContext {
16
16
  }
17
17
 
18
18
  function primaryArgument(args: Record<string, unknown>): string | undefined {
19
- for (const key of ["id", "title", "text", "query", "kind", "template_id"]) {
19
+ // name/title before id: a caller that already knows the name shouldn't have the raw id echoed
20
+ // back at it; id only surfaces here when it's genuinely the only identifying argument given.
21
+ for (const key of ["name", "title", "id", "text", "query", "kind", "template_id"]) {
20
22
  const value = args[key];
21
23
  if (typeof value === "string" && value.trim()) return value.slice(0, CALL_VALUE_MAX_CHARACTERS);
22
24
  }
@@ -45,11 +47,11 @@ function textContent(result: AgentToolResult<unknown>): string {
45
47
  function simpleDetailsText(details: Exclude<PapyrusToolDetails, { kind: "artifact" | "artifact-list" | "graph" }>): string {
46
48
  switch (details.kind) {
47
49
  case "transition":
48
- return `✓ ${details.artifact.id} ${details.fromStatus} → ${details.toStatus}\n${details.artifact.title}`;
50
+ return `✓ ${details.fromStatus} → ${details.toStatus}\n${details.artifact.title}`;
49
51
  case "gate-run": {
50
52
  const passed = details.gates.filter((gate) => gate.passed).length;
51
53
  return [
52
- `${passed}/${details.gates.length} gates passed for ${details.artifactId}`,
54
+ `${passed}/${details.gates.length} gates passed for "${details.artifactTitle}"`,
53
55
  ...details.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.type}: ${gate.target}${gate.output ? ` — ${gate.output}` : ""}`),
54
56
  ].join("\n");
55
57
  }
@@ -81,6 +81,7 @@ export interface ToolGateRow {
81
81
  export interface GateRunToolDetails extends ToolDetailsBase {
82
82
  kind: "gate-run";
83
83
  artifactId: string;
84
+ artifactTitle: string;
84
85
  gates: ToolGateRow[];
85
86
  completeness: ResultCompleteness;
86
87
  }
@@ -218,6 +219,7 @@ export function createGraphDetails(
218
219
  export function createGateRunDetails(
219
220
  operation: string,
220
221
  artifactId: string,
222
+ artifactTitle: string,
221
223
  gates: readonly ToolGateRow[],
222
224
  ): GateRunToolDetails {
223
225
  const boundedGates = gates.slice(0, TOOL_DETAILS_MAX_ITEMS).map((gate) => ({
@@ -229,6 +231,7 @@ export function createGateRunDetails(
229
231
  kind: "gate-run",
230
232
  operation,
231
233
  artifactId,
234
+ artifactTitle,
232
235
  gates: boundedGates,
233
236
  completeness: completeness(gates.length, boundedGates.length),
234
237
  };
@@ -383,6 +386,7 @@ export function parsePapyrusToolDetails(value: unknown): PapyrusToolDetails | un
383
386
  ? value as unknown as GraphToolDetails : undefined;
384
387
  case "gate-run":
385
388
  return isBoundedString(value.artifactId)
389
+ && isBoundedString(value.artifactTitle)
386
390
  && isBoundedArray(value.gates, TOOL_DETAILS_MAX_ITEMS, isGateRow)
387
391
  && isCompleteness(value.completeness)
388
392
  ? value as unknown as GateRunToolDetails : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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"],