@danypops/pi-papyrus 0.38.4 → 0.38.5
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/extension/src/domain-tools.ts +85 -14
- package/package.json +1 -1
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
type DiscussionRound,
|
|
9
9
|
type GateResult,
|
|
10
10
|
type OperationName,
|
|
11
|
+
type PlaybookInvocationResult,
|
|
12
|
+
type PlaybookMissingArguments,
|
|
11
13
|
type WorkflowRunResult,
|
|
12
14
|
type TaskCompletion,
|
|
13
15
|
type TaskExecutionPlan,
|
|
@@ -138,16 +140,49 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
|
|
|
138
140
|
return matches[0]!.id;
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* tasks.list is the one list operation that requires `project_root` and separately supports a
|
|
145
|
+
* `scope` ("project" | "graph" | "all") to widen or narrow the search. Every other list operation
|
|
146
|
+
* (docs.list, rules.list, skills.list, playbooks.list, artifact.query, ...) instead treats an
|
|
147
|
+
* omitted `project_root` as an unscoped/global search (domain-services.ts's listScoped) and has
|
|
148
|
+
* no `scope` concept at all -- so "search everywhere" means something different for each.
|
|
149
|
+
*/
|
|
150
|
+
const SCOPE_AWARE_LIST_OPERATIONS = new Set<OperationName>(["tasks.list"]);
|
|
151
|
+
|
|
152
|
+
/** The widened-scope request tried once when a name isn't found under the caller's current scope. */
|
|
153
|
+
function widenedRequest(listOperation: OperationName, baseRequest: Record<string, unknown>): Record<string, unknown> {
|
|
154
|
+
return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation)
|
|
155
|
+
? { ...baseRequest, scope: "all" }
|
|
156
|
+
: { ...baseRequest, project_root: undefined };
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
/**
|
|
142
160
|
* Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
|
|
143
161
|
* scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
|
|
144
162
|
* kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
|
|
145
163
|
* should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
|
|
146
164
|
* uses, so resolution never searches a wider or narrower scope than a plain list call would.
|
|
165
|
+
*
|
|
166
|
+
* A two-artifact action (depend/contain/gate/link) routinely names artifacts that live in two
|
|
167
|
+
* different projects, and one call has no way to give two different name fields two different
|
|
168
|
+
* scopes. When the first lookup finds nothing under the caller's current scope, retry exactly
|
|
169
|
+
* once against a global search before giving up -- but never when the caller already pinned an
|
|
170
|
+
* explicit `scope`, so a genuine "not found in the scope I asked for" stays a real error instead
|
|
171
|
+
* of being silently papered over. `notes`, when given, records that a name only resolved after
|
|
172
|
+
* widening, so the caller can surface that a search went wider than the caller's default scope
|
|
173
|
+
* rather than resolving silently.
|
|
147
174
|
*/
|
|
148
|
-
async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string): Promise<string> {
|
|
175
|
+
async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string, notes?: string[]): Promise<string> {
|
|
149
176
|
const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
|
|
150
|
-
|
|
177
|
+
try {
|
|
178
|
+
return matchArtifactByName(candidates, name);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest["scope"] !== undefined) throw error;
|
|
181
|
+
const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...widenedRequest(listOperation, baseRequest), text: name });
|
|
182
|
+
const id = matchArtifactByName(widenedCandidates, name);
|
|
183
|
+
notes?.push(`"${name}" was not found in the current project scope; resolved across all projects instead.`);
|
|
184
|
+
return id;
|
|
185
|
+
}
|
|
151
186
|
}
|
|
152
187
|
|
|
153
188
|
/**
|
|
@@ -167,15 +202,21 @@ export function normalizeJsonEncodedField(params: Record<string, unknown>, key:
|
|
|
167
202
|
}
|
|
168
203
|
}
|
|
169
204
|
|
|
170
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in
|
|
207
|
+
* place. `notes`, when given, collects a message for each name that only resolved by widening
|
|
208
|
+
* past the caller's own scope (see resolveArtifactIdByName) -- callers that want that surfaced
|
|
209
|
+
* to the model/human pass an array here and append it to their own response text.
|
|
210
|
+
*/
|
|
171
211
|
export async function resolveNameFields(
|
|
172
212
|
params: Record<string, unknown>,
|
|
173
213
|
fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
|
|
214
|
+
notes?: string[],
|
|
174
215
|
): Promise<void> {
|
|
175
216
|
for (const { nameKey, idKey, listOperation, baseRequest } of fields) {
|
|
176
217
|
const nameValue = params[nameKey];
|
|
177
218
|
if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
|
|
178
|
-
params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue);
|
|
219
|
+
params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue, notes);
|
|
179
220
|
}
|
|
180
221
|
}
|
|
181
222
|
}
|
|
@@ -187,10 +228,11 @@ async function resolveNameArrayField(
|
|
|
187
228
|
idsKey: string,
|
|
188
229
|
listOperation: OperationName,
|
|
189
230
|
baseRequest: Record<string, unknown>,
|
|
231
|
+
notes?: string[],
|
|
190
232
|
): Promise<void> {
|
|
191
233
|
const names = params[namesKey];
|
|
192
234
|
if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
|
|
193
|
-
params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry))));
|
|
235
|
+
params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry), notes)));
|
|
194
236
|
}
|
|
195
237
|
}
|
|
196
238
|
|
|
@@ -248,7 +290,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
248
290
|
pi.registerTool({
|
|
249
291
|
name: "tasks",
|
|
250
292
|
label: "Tasks",
|
|
251
|
-
description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `
|
|
293
|
+
description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `parent_name`/`child_name`/`root_task_name` are the same pattern for their `_id` counterparts. For a prerequisite, use `dependency_name` (singular, resolved to `dependency_id`) with the `depend`/`undepend` actions; `depends_on_names` (plural array, resolved to `depends_on`) is only for `create`'s initial dependency set -- passing the wrong one of the two to `depend` leaves `dependency_id` unset and fails with a `dependency_id is required` error. A name resolved outside this call's own project scope (e.g. depending on a task in a different project) is retried once against every project before failing, and the response notes when that happened.",
|
|
252
294
|
parameters: Type.Object({
|
|
253
295
|
action: Type.String(),
|
|
254
296
|
id: Type.Optional(Type.String()),
|
|
@@ -299,12 +341,17 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
299
341
|
// ever holds this extension's own registered session anyway (see session-identity.ts).
|
|
300
342
|
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
301
343
|
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
|
|
344
|
+
// Collects a note whenever a name field below only resolved by widening past this call's
|
|
345
|
+
// own project scope (see resolveArtifactIdByName) -- surfaced at the end of this action's
|
|
346
|
+
// own response text rather than resolved silently, since a cross-project depend/contain
|
|
347
|
+
// is exactly the case a shared per-call scope can't otherwise express.
|
|
348
|
+
const notes: string[] = [];
|
|
302
349
|
// Resolve the graph root first: every other name lookup must use the caller's final
|
|
303
350
|
// project/scope/root selection, otherwise `scope: all|graph` silently collapses back
|
|
304
351
|
// to the current project and forces callers to reach for an id.
|
|
305
352
|
await resolveNameFields(params, [
|
|
306
353
|
{ nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
|
|
307
|
-
]);
|
|
354
|
+
], notes);
|
|
308
355
|
const resolutionRequest = {
|
|
309
356
|
...baseRequest,
|
|
310
357
|
...(params.scope === undefined ? {} : { scope: params.scope }),
|
|
@@ -317,9 +364,10 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
317
364
|
{ nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
318
365
|
{ nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
319
366
|
{ nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
320
|
-
]);
|
|
321
|
-
await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest);
|
|
367
|
+
], notes);
|
|
368
|
+
await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
|
|
322
369
|
const request = { ...params, ...baseRequest };
|
|
370
|
+
const result = await (async (): Promise<ReturnType<typeof text>> => {
|
|
323
371
|
if (action === "create") {
|
|
324
372
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
325
373
|
return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
|
|
@@ -482,6 +530,9 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
482
530
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
|
|
483
531
|
if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
|
|
484
532
|
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
533
|
+
})();
|
|
534
|
+
if (notes.length > 0 && result.content[0]?.type === "text") result.content[0].text += `\n\n${notes.join("\n")}`;
|
|
535
|
+
return result;
|
|
485
536
|
} catch (error) {
|
|
486
537
|
throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
|
|
487
538
|
}
|
|
@@ -672,13 +723,33 @@ export function registerPlaybooksTool(pi: ExtensionAPI): void {
|
|
|
672
723
|
return text(rendered, createPreviewDetails("playbooks.preview", "Playbook preview", rendered));
|
|
673
724
|
}
|
|
674
725
|
if (action === "invoke") {
|
|
675
|
-
const invocation = await callService<Record<string, unknown>,
|
|
676
|
-
if (invocation
|
|
726
|
+
const invocation = await callService<Record<string, unknown>, PlaybookInvocationResult | PlaybookMissingArguments>("playbooks.invoke", params);
|
|
727
|
+
if ("missingArguments" in invocation) {
|
|
677
728
|
const message = `Missing required argument(s): ${invocation.missingArguments.join(", ")}. Nothing was created -- ask the human for these (discuss tool, live:true), then invoke again.`;
|
|
678
|
-
return text(message,
|
|
729
|
+
return text(message, createInvocationDetails("playbooks.invoke", invocation.playbookId, { tasks: [], docs: [], rules: [], roots: [] }));
|
|
679
730
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
731
|
+
const nodeTitleCounts = new Map<string, number>();
|
|
732
|
+
for (const node of invocation.execution.nodes) nodeTitleCounts.set(node.title, (nodeTitleCounts.get(node.title) ?? 0) + 1);
|
|
733
|
+
const execution = invocation.execution.nodes.map((node) => (nodeTitleCounts.get(node.title) ?? 0) > 1
|
|
734
|
+
? ` [${node.state}] ${node.title} (${node.id})`
|
|
735
|
+
: ` [${node.state}] ${node.title}`).join("\n");
|
|
736
|
+
const nodeById = new Map(invocation.execution.nodes.map((node) => [node.id, node]));
|
|
737
|
+
const rootLabels = invocation.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
738
|
+
const entryLabel = nodeById.get(invocation.entryTaskId)?.title ?? invocation.entryTaskId;
|
|
739
|
+
const createdLabels = await artifactLabelsById([...invocation.created.docs, ...invocation.created.rules]);
|
|
740
|
+
return text([
|
|
741
|
+
`Invoked playbook run ${invocation.runId}: ${invocation.created.tasks.length} task(s), ${invocation.created.rules.length} rule(s), ${invocation.created.docs.length} doc(s) created.`,
|
|
742
|
+
`Entry task now focused: ${entryLabel}. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`,
|
|
743
|
+
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
744
|
+
`Context docs: ${invocation.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
745
|
+
`Scoped rules: ${invocation.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
746
|
+
...(execution ? ["Execution:", execution] : []),
|
|
747
|
+
].join("\n"), createInvocationDetails("playbooks.invoke", invocation.runId, {
|
|
748
|
+
tasks: invocation.created.tasks,
|
|
749
|
+
docs: invocation.created.docs,
|
|
750
|
+
rules: invocation.created.rules,
|
|
751
|
+
roots: invocation.rootTaskIds,
|
|
752
|
+
}));
|
|
682
753
|
}
|
|
683
754
|
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
684
755
|
if (trashResult) return trashResult;
|
package/package.json
CHANGED