@danypops/papyrus 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -179,6 +179,8 @@ papyrus discuss show <discussion-id> --json
179
179
 
180
180
  ## Tasks
181
181
 
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
+
182
184
  Run `/tasks` for the interactive task panel:
183
185
 
184
186
  - `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
@@ -33,6 +33,47 @@ function artifactLine(artifact: Artifact): string {
33
33
  return `${artifact.id} [${artifact.status}] ${artifact.title}`;
34
34
  }
35
35
 
36
+ /**
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.
43
+ */
44
+ export function taskLine(task: Artifact): string {
45
+ return `[${task.status}] ${task.title}`;
46
+ }
47
+
48
+ /** Appends " (id)" only for tasks whose title collides with another in this same result set. */
49
+ export function taskLines(tasks: Artifact[]): string[] {
50
+ 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)));
53
+ }
54
+
55
+ /**
56
+ * Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
57
+ * a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
58
+ * remains the one truly unambiguous key, so ambiguity is exactly where it's allowed to resurface.
59
+ * Pure and synchronous so it's directly testable without a service round-trip.
60
+ */
61
+ export function matchTaskByName(candidates: Artifact[], name: string): string {
62
+ 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`);
65
+ 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`);
67
+ }
68
+ return matches[0]!.id;
69
+ }
70
+
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);
75
+ }
76
+
36
77
  /**
37
78
  * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
38
79
  * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
@@ -67,10 +108,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
67
108
  pi.registerTool({
68
109
  name: "tasks",
69
110
  label: "Tasks",
70
- description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). Prefer this over low-level papyrus_* tools for task work.",
111
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `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`. Prefer this over low-level papyrus_* tools for task work.",
71
112
  parameters: Type.Object({
72
113
  action: Type.String(),
73
114
  id: Type.Optional(Type.String()),
115
+ name: Type.Optional(Type.String()),
74
116
  title: Type.Optional(Type.String()),
75
117
  body: Type.Optional(Type.String()),
76
118
  status: Type.Optional(Type.String()),
@@ -86,17 +128,23 @@ export function registerDomainTools(pi: ExtensionAPI): void {
86
128
  checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
87
129
  template_id: Type.Optional(Type.String()),
88
130
  parent_id: Type.Optional(Type.String()),
131
+ parent_name: Type.Optional(Type.String()),
89
132
  child_id: Type.Optional(Type.String()),
133
+ child_name: Type.Optional(Type.String()),
90
134
  dependency_id: Type.Optional(Type.String()),
135
+ dependency_name: Type.Optional(Type.String()),
91
136
  depends_on: Type.Optional(Type.Array(Type.String())),
137
+ depends_on_names: Type.Optional(Type.Array(Type.String())),
92
138
  project_root: Type.Optional(Type.String()),
93
139
  scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
94
140
  root_task_id: Type.Optional(Type.String()),
141
+ root_task_name: Type.Optional(Type.String()),
95
142
  }),
96
143
  renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
97
144
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
98
- async execute(_id, params, _signal, _onUpdate, ctx) {
145
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
99
146
  try {
147
+ const params: Record<string, unknown> = { ...rawParams };
100
148
  const action = params.action;
101
149
  // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
102
150
  // without depending on the model to know or supply its own session identity.
@@ -105,18 +153,37 @@ export function registerDomainTools(pi: ExtensionAPI): void {
105
153
  // session never gets this session's secret smuggled in on its behalf -- the cache only
106
154
  // ever holds this extension's own registered session anyway (see session-identity.ts).
107
155
  const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
108
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
156
+ const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
157
+ // Resolves every *_name field to its *_id counterpart before dispatch, so every action
158
+ // below can go on reading id/dependency_id/parent_id/child_id/root_task_id exactly as
159
+ // 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
+ }
175
+ const request = { ...params, ...baseRequest };
109
176
  if (action === "create") {
110
177
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
111
- return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
178
+ return text(`Created task ${taskLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
112
179
  }
113
180
  if (action === "list") {
114
181
  const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
115
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
182
+ return text(rows.length ? taskLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
116
183
  }
117
184
  if (action === "show") {
118
185
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
119
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
186
+ return text(`${taskLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
120
187
  }
121
188
  if (action === "history") {
122
189
  const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
@@ -131,20 +198,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
131
198
  if (action === "active") {
132
199
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
133
200
  return artifact
134
- ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
201
+ ? text(`Active: ${taskLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
135
202
  : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
136
203
  }
137
204
  if (action === "focused") {
138
205
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
139
206
  return focus
140
- ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
207
+ ? text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
141
208
  : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
142
209
  }
143
210
  if (action === "pause" || action === "unpause") {
144
211
  const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
145
212
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
146
213
  emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
147
- return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
214
+ return text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
148
215
  }
149
216
  if (action === "clear_focus") {
150
217
  const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
@@ -168,11 +235,16 @@ export function registerDomainTools(pi: ExtensionAPI): void {
168
235
  if (action === "plan") {
169
236
  const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
170
237
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
238
+ const titleCounts = new Map<string, number>();
239
+ for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
171
240
  const lines = plan.layers.flatMap((layer, index) => [
172
241
  `Layer ${index + 1}`,
173
242
  ...layer.map((id) => {
174
243
  const node = byId.get(id);
175
- return node ? ` [${node.state}] ${node.id} ${node.title}` : ` [unknown] ${id}`;
244
+ if (!node) return ` [unknown] ${id}`;
245
+ return (titleCounts.get(node.title) ?? 0) > 1
246
+ ? ` [${node.state}] ${node.title} (${node.id})`
247
+ : ` [${node.state}] ${node.title}`;
176
248
  }),
177
249
  ]);
178
250
  if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.join(", ")}`);
@@ -181,24 +253,25 @@ export function registerDomainTools(pi: ExtensionAPI): void {
181
253
  }
182
254
  if (action === "set_checklist") {
183
255
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
184
- return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
256
+ return text(`Updated checklist: ${taskLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
185
257
  }
186
258
  if (action === "complete") {
187
259
  const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
188
260
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
189
261
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
190
- const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
262
+ const focused = result.focused ? `\nActive: ${taskLine(result.focused)}` : "";
263
+ const blockedLines = taskLines(result.blocked.map((entry) => entry.artifact));
191
264
  const blocked = result.blocked.length > 0
192
- ? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
265
+ ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
193
266
  : "";
194
- const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
267
+ const output = `${result.completed ? "Completed" : "Rejected"}: ${taskLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
195
268
  return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
196
269
  }
197
270
  if (action === "run_gates") {
198
271
  const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
199
272
  return text(
200
273
  gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
201
- createGateRunDetails("tasks.run_gates", params.id ?? "", gates.map((gate) => ({
274
+ createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", gates.map((gate) => ({
202
275
  passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
203
276
  }))),
204
277
  );
@@ -224,7 +297,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
224
297
  if (!operation) throw new Error(`unknown tasks action: ${action}`);
225
298
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
226
299
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
227
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
300
+ return text(taskLine(artifact), createArtifactDetails(operation, artifact));
228
301
  } catch (error) {
229
302
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
230
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
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"],