@danypops/papyrus 0.34.2 → 0.35.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 +5 -189
- package/package.json +8 -16
- package/src/artifact-relationship-view.ts +23 -0
- package/src/cli.ts +0 -0
- package/src/index.ts +32 -0
- package/src/task-relationship-view.ts +2 -1
- package/extension/src/active-task-continuation.ts +0 -131
- package/extension/src/artifact-browser.ts +0 -229
- package/extension/src/artifact-detail-format.ts +0 -31
- package/extension/src/artifact-detail-view.ts +0 -112
- package/extension/src/artifact-format.ts +0 -84
- package/extension/src/artifact-status-presentation.ts +0 -71
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/beautiful-mermaid-renderer.ts +0 -68
- package/extension/src/bounded-poll.ts +0 -20
- package/extension/src/context-budget.ts +0 -503
- package/extension/src/context-injection-telemetry.ts +0 -88
- package/extension/src/context-view.ts +0 -222
- package/extension/src/discuss-ask-layout.ts +0 -193
- package/extension/src/discuss-ask-view.ts +0 -1301
- package/extension/src/discuss.ts +0 -134
- package/extension/src/discussion-detail-view.ts +0 -136
- package/extension/src/docs.ts +0 -58
- package/extension/src/domain-tools.ts +0 -886
- package/extension/src/index.ts +0 -776
- package/extension/src/markdown.ts +0 -60
- package/extension/src/note-widget.ts +0 -8
- package/extension/src/notes.ts +0 -102
- package/extension/src/playbook-bridge.ts +0 -91
- package/extension/src/playbooks.ts +0 -97
- package/extension/src/rules.ts +0 -51
- package/extension/src/service-client.ts +0 -29
- package/extension/src/session-identity.ts +0 -22
- package/extension/src/skill-catalog-footprint.ts +0 -183
- package/extension/src/skills.ts +0 -127
- package/extension/src/task-context.ts +0 -1
- package/extension/src/task-detail-format.ts +0 -110
- package/extension/src/task-detail-view.ts +0 -139
- package/extension/src/task-focus-events.ts +0 -57
- package/extension/src/task-graph.ts +0 -116
- package/extension/src/task-presentation.ts +0 -26
- package/extension/src/task-widget.ts +0 -70
- package/extension/src/tasks.ts +0 -418
- package/extension/src/tool-rendering/artifact-card.ts +0 -117
- package/extension/src/tool-rendering/artifact-list.ts +0 -179
- package/extension/src/tool-rendering/index.ts +0 -109
- package/extension/src/tool-rendering/render-model.ts +0 -410
|
@@ -1,886 +0,0 @@
|
|
|
1
|
-
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Type } from "typebox";
|
|
3
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
|
-
import type { TaskLease } from "../../src/domain/task-lease.ts";
|
|
5
|
-
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
6
|
-
import type { GateResult } from "../../src/domain/gate.ts";
|
|
7
|
-
import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
8
|
-
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
9
|
-
import type { NoteHistoryPage } from "../../src/domain/note-event.ts";
|
|
10
|
-
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
11
|
-
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
12
|
-
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
13
|
-
import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
|
|
14
|
-
import { askQuestion } from "./discuss-ask-view.ts";
|
|
15
|
-
import type { OperationName } from "../../src/service.ts";
|
|
16
|
-
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
17
|
-
import { sessionSecretField } from "./session-identity.ts";
|
|
18
|
-
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
19
|
-
import { callService } from "./service-client.ts";
|
|
20
|
-
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
21
|
-
import {
|
|
22
|
-
createArtifactDetails,
|
|
23
|
-
createArtifactListDetails,
|
|
24
|
-
createGateRunDetails,
|
|
25
|
-
createGraphDetails,
|
|
26
|
-
createInvocationDetails,
|
|
27
|
-
createModelContent,
|
|
28
|
-
createPreviewDetails,
|
|
29
|
-
} from "./tool-rendering/render-model.ts";
|
|
30
|
-
|
|
31
|
-
function text(message: string, details: unknown = {}) {
|
|
32
|
-
const modelContent = createModelContent(message);
|
|
33
|
-
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* live:true's synchronous half: reuses the same Discuss-owned ask UI (discuss-ask-view.ts) the
|
|
38
|
-
* /discuss TUI's own "Reply" action uses when the just-created round posed a structured choice,
|
|
39
|
-
* or a plain freeform prompt otherwise -- so "ask" covers both a completely open question and a
|
|
40
|
-
* choice tied to this specific Discussion. Returns undefined on cancel or when no interactive UI
|
|
41
|
-
* is available, never throws -- an unanswered live prompt still leaves the round it already
|
|
42
|
-
* recorded intact.
|
|
43
|
-
*/
|
|
44
|
-
/**
|
|
45
|
-
* The discuss tool accepts each option as either a bare string (self-evident choices) or
|
|
46
|
-
* {title, description} (a real tradeoff worth spelling out) -- normalizes to the two parallel
|
|
47
|
-
* arrays discuss.open/discuss.reply actually expect (options: string[], option_descriptions:
|
|
48
|
-
* string[], index-aligned, empty string meaning "none for this one"). Mutates params in place.
|
|
49
|
-
*/
|
|
50
|
-
function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
51
|
-
const raw = params.options;
|
|
52
|
-
if (!Array.isArray(raw)) return;
|
|
53
|
-
let anyDescription = false;
|
|
54
|
-
const titles: string[] = [];
|
|
55
|
-
const descriptions: string[] = [];
|
|
56
|
-
for (const entry of raw) {
|
|
57
|
-
if (typeof entry === "string") { titles.push(entry); descriptions.push(""); continue; }
|
|
58
|
-
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
|
|
59
|
-
const record = entry as Record<string, unknown>;
|
|
60
|
-
titles.push(record.title as string);
|
|
61
|
-
const description = typeof record.description === "string" ? record.description : "";
|
|
62
|
-
if (description) anyDescription = true;
|
|
63
|
-
descriptions.push(description);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
titles.push(String(entry));
|
|
67
|
-
descriptions.push("");
|
|
68
|
-
}
|
|
69
|
-
params.options = titles;
|
|
70
|
-
if (anyDescription) params.option_descriptions = descriptions;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
74
|
-
if (!ctx.hasUI) return undefined;
|
|
75
|
-
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
76
|
-
// The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
|
|
77
|
-
// wrapper as the primary question, with the real content demoted to "Context:", left a human
|
|
78
|
-
// staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
|
|
79
|
-
// the degenerate case of empty content; the title becomes a plain orientation subtitle instead.
|
|
80
|
-
const question = latestContent?.trim() || `Reply to "${discussion.title}":`;
|
|
81
|
-
const subtitle = discussion.title;
|
|
82
|
-
if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
|
|
83
|
-
return askQuestion(ctx, {
|
|
84
|
-
question,
|
|
85
|
-
subtitle,
|
|
86
|
-
options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })),
|
|
87
|
-
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
88
|
-
onUpdate,
|
|
89
|
-
signal,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
return askQuestion(ctx, { question, subtitle, onUpdate, signal });
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
|
|
97
|
-
* backend implementation detail (a stable key other operations need, and titles aren't
|
|
98
|
-
* guaranteed unique), so it stays out of what the model reads by default. It only resurfaces
|
|
99
|
-
* when genuinely needed to tell two same-titled artifacts apart (artifactLines below), or in a
|
|
100
|
-
* matchArtifactByName disambiguation error, never as a matter of course.
|
|
101
|
-
*/
|
|
102
|
-
export function artifactLine(artifact: Artifact): string {
|
|
103
|
-
return `[${artifact.status}] ${artifact.title}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Appends " (id)" only for artifacts whose title collides with another in this same result set. */
|
|
107
|
-
export function artifactLines(artifacts: Artifact[]): string[] {
|
|
108
|
-
const titleCounts = new Map<string, number>();
|
|
109
|
-
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
110
|
-
return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Resolves internal ids for model text; ids resurface only when equal titles need disambiguation. */
|
|
114
|
-
async function artifactLabelsById(ids: readonly string[]): Promise<Map<string, string>> {
|
|
115
|
-
const uniqueIds = [...new Set(ids)];
|
|
116
|
-
const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
|
|
117
|
-
const titleCounts = new Map<string, number>();
|
|
118
|
-
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
119
|
-
return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
|
|
124
|
-
* a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
|
|
125
|
-
* remains the one truly unambiguous key, so ambiguity is exactly where it's allowed to resurface.
|
|
126
|
-
* Pure and synchronous so it's directly testable without a service round-trip.
|
|
127
|
-
*/
|
|
128
|
-
export function matchArtifactByName(candidates: Artifact[], name: string): string {
|
|
129
|
-
const needle = name.trim().toLowerCase();
|
|
130
|
-
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
131
|
-
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
132
|
-
if (matches.length > 1) {
|
|
133
|
-
throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((artifact) => `${artifact.title} (${artifact.id})`).join(", ")} -- use id to disambiguate`);
|
|
134
|
-
}
|
|
135
|
-
return matches[0]!.id;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Resolves a name to its id via `listOperation` (whichever kind's list call is the right search
|
|
140
|
-
* scope -- tasks.list, docs.list, rules.list, skills.list, notes.list, discuss.list, or the
|
|
141
|
-
* kind-agnostic artifact.query for a cross-kind reference like a link target). `baseRequest`
|
|
142
|
-
* should mirror whatever scoping (project_root, etc.) that operation's own "list" action already
|
|
143
|
-
* uses, so resolution never searches a wider or narrower scope than a plain list call would.
|
|
144
|
-
*/
|
|
145
|
-
async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string): Promise<string> {
|
|
146
|
-
const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
|
|
147
|
-
return matchArtifactByName(candidates, name);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Playbook `arguments` is intentionally untyped in this tool's schema (an array on create, a
|
|
152
|
-
* {name: value} map on invoke) -- unlike every other JSON-shaped field here, which has a concrete
|
|
153
|
-
* array/record schema the calling layer can serialize correctly. A genuinely schema-less field can
|
|
154
|
-
* arrive pre-serialized as JSON text instead of a parsed value; parse it back in place before it
|
|
155
|
-
* reaches the service, the same tolerance the CLI's own --arguments-json/--*-json flags already give.
|
|
156
|
-
*/
|
|
157
|
-
export function normalizeJsonEncodedField(params: Record<string, unknown>, key: string): void {
|
|
158
|
-
const value = params[key];
|
|
159
|
-
if (typeof value !== "string") return;
|
|
160
|
-
try {
|
|
161
|
-
params[key] = JSON.parse(value);
|
|
162
|
-
} catch {
|
|
163
|
-
throw new Error(`${key} must be valid JSON`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
|
|
168
|
-
export async function resolveNameFields(
|
|
169
|
-
params: Record<string, unknown>,
|
|
170
|
-
fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
|
|
171
|
-
): Promise<void> {
|
|
172
|
-
for (const { nameKey, idKey, listOperation, baseRequest } of fields) {
|
|
173
|
-
const nameValue = params[nameKey];
|
|
174
|
-
if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
|
|
175
|
-
params[idKey] = await resolveArtifactIdByName(listOperation, baseRequest, nameValue);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Resolves a `namesKey` string array to an `idsKey` id array, only when idsKey isn't already explicitly given. */
|
|
181
|
-
async function resolveNameArrayField(
|
|
182
|
-
params: Record<string, unknown>,
|
|
183
|
-
namesKey: string,
|
|
184
|
-
idsKey: string,
|
|
185
|
-
listOperation: OperationName,
|
|
186
|
-
baseRequest: Record<string, unknown>,
|
|
187
|
-
): Promise<void> {
|
|
188
|
-
const names = params[namesKey];
|
|
189
|
-
if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
|
|
190
|
-
params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry))));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
|
|
196
|
-
* artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
|
|
197
|
-
* not owned by any one domain module, so every domain tool exposes the same two actions
|
|
198
|
-
* over the same two operations rather than reinventing trash semantics four times.
|
|
199
|
-
* Returns null when action is neither, so callers fall through to their own dispatch.
|
|
200
|
-
*/
|
|
201
|
-
async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
|
|
202
|
-
// Trashed/restored artifacts stay directly showable, so known identities render by title on
|
|
203
|
-
// either side of the action. An unresolved explicit id stays in structured/error channels;
|
|
204
|
-
// normal model text does not turn that backend key into the artifact's public name.
|
|
205
|
-
const titleOf = async (): Promise<string> => {
|
|
206
|
-
try {
|
|
207
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params["id"] });
|
|
208
|
-
return artifact ? `"${artifact.title}"` : "unknown artifact";
|
|
209
|
-
} catch {
|
|
210
|
-
return "unknown artifact";
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
if (action === "remove") {
|
|
214
|
-
const label = await titleOf();
|
|
215
|
-
const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
|
|
216
|
-
const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
|
|
217
|
-
return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
|
|
218
|
-
}
|
|
219
|
-
if (action === "restore") {
|
|
220
|
-
const label = await titleOf();
|
|
221
|
-
const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
|
|
222
|
-
const output = outcome.restored ? `Restored ${label}.` : `${label} was not trashed.`;
|
|
223
|
-
return text(output, createPreviewDetails("artifact.restore", "Restored", output));
|
|
224
|
-
}
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const proofReferenceSchema = Type.Object({
|
|
229
|
-
type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
|
|
230
|
-
target: Type.String(),
|
|
231
|
-
expect: Type.Optional(Type.String()),
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const checklistCriterionSchema = Type.Object({
|
|
235
|
-
proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
export function registerTasksTool(pi: ExtensionAPI): void {
|
|
239
|
-
pi.registerTool({
|
|
240
|
-
name: "tasks",
|
|
241
|
-
label: "Tasks",
|
|
242
|
-
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, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore, claim, heartbeat_lease, release_lease, lease, event_feed. context returns the full current/desired/verify reconciliation plan for the active task(s) -- the system prompt is injected with only a one-line pointer to save tokens on turns that don't need it; call this explicitly when you actually need the full plan (e.g. after a compaction, or before reconciling). 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). claim/heartbeat_lease/release_lease/lease manage a bounded work-reservation lease -- independent of both lifecycle status and Focus, so multiple sessions can Focus the same task while only one owner holds its lease at a time; claim throws if a DIFFERENT owner already holds a live lease, release/heartbeat require the exact token claim returned. `owner` defaults to this session's own id when omitted. 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.",
|
|
243
|
-
parameters: Type.Object({
|
|
244
|
-
action: Type.String(),
|
|
245
|
-
id: Type.Optional(Type.String()),
|
|
246
|
-
name: Type.Optional(Type.String()),
|
|
247
|
-
title: Type.Optional(Type.String()),
|
|
248
|
-
body: Type.Optional(Type.String()),
|
|
249
|
-
status: Type.Optional(Type.String()),
|
|
250
|
-
text: Type.Optional(Type.String()),
|
|
251
|
-
limit: Type.Optional(Type.Number()),
|
|
252
|
-
cursor: Type.Optional(Type.Number()),
|
|
253
|
-
direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
|
|
254
|
-
reason: Type.Optional(Type.String()),
|
|
255
|
-
session_id: Type.Optional(Type.String()),
|
|
256
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
257
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
258
|
-
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
259
|
-
checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
|
|
260
|
-
template_id: Type.Optional(Type.String()),
|
|
261
|
-
parent_id: Type.Optional(Type.String()),
|
|
262
|
-
parent_name: Type.Optional(Type.String()),
|
|
263
|
-
child_id: Type.Optional(Type.String()),
|
|
264
|
-
child_name: Type.Optional(Type.String()),
|
|
265
|
-
dependency_id: Type.Optional(Type.String()),
|
|
266
|
-
dependency_name: Type.Optional(Type.String()),
|
|
267
|
-
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
268
|
-
depends_on_names: Type.Optional(Type.Array(Type.String())),
|
|
269
|
-
project_root: Type.Optional(Type.String()),
|
|
270
|
-
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
|
|
271
|
-
root_task_id: Type.Optional(Type.String()),
|
|
272
|
-
root_task_name: Type.Optional(Type.String()),
|
|
273
|
-
owner: Type.Optional(Type.String()),
|
|
274
|
-
token: Type.Optional(Type.String()),
|
|
275
|
-
ttl_ms: Type.Optional(Type.Number()),
|
|
276
|
-
note: Type.Optional(Type.String()),
|
|
277
|
-
event_types: Type.Optional(Type.Array(Type.String())),
|
|
278
|
-
}),
|
|
279
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
|
|
280
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
281
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
282
|
-
try {
|
|
283
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
284
|
-
const action = params.action;
|
|
285
|
-
// Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
|
|
286
|
-
// without depending on the model to know or supply its own session identity.
|
|
287
|
-
// session_secret is looked up by the resolved session_id itself (not blindly the
|
|
288
|
-
// current session's), so a model that explicitly overrides session_id to a DIFFERENT
|
|
289
|
-
// session never gets this session's secret smuggled in on its behalf -- the cache only
|
|
290
|
-
// ever holds this extension's own registered session anyway (see session-identity.ts).
|
|
291
|
-
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
292
|
-
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
|
|
293
|
-
// Resolve the graph root first: every other name lookup must use the caller's final
|
|
294
|
-
// project/scope/root selection, otherwise `scope: all|graph` silently collapses back
|
|
295
|
-
// to the current project and forces callers to reach for an id.
|
|
296
|
-
await resolveNameFields(params, [
|
|
297
|
-
{ nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
|
|
298
|
-
]);
|
|
299
|
-
const resolutionRequest = {
|
|
300
|
-
...baseRequest,
|
|
301
|
-
...(params.scope === undefined ? {} : { scope: params.scope }),
|
|
302
|
-
...(params.root_task_id === undefined ? {} : { root_task_id: params.root_task_id }),
|
|
303
|
-
};
|
|
304
|
-
// The daemon remains keyed by stable ids; the agent facade resolves names against the
|
|
305
|
-
// exact requested view before dispatching those internal ids.
|
|
306
|
-
await resolveNameFields(params, [
|
|
307
|
-
{ nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
308
|
-
{ nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
309
|
-
{ nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
310
|
-
{ nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
311
|
-
]);
|
|
312
|
-
await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest);
|
|
313
|
-
const request = { ...params, ...baseRequest };
|
|
314
|
-
if (action === "create") {
|
|
315
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
316
|
-
return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
|
|
317
|
-
}
|
|
318
|
-
if (action === "list") {
|
|
319
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
|
|
320
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
|
|
321
|
-
}
|
|
322
|
-
if (action === "show") {
|
|
323
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
324
|
-
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
|
|
325
|
-
}
|
|
326
|
-
if (action === "history") {
|
|
327
|
-
const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
|
|
328
|
-
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
329
|
-
const output = lines.join("\n") || "No recorded history for this task.";
|
|
330
|
-
return text(output, createPreviewDetails("tasks.history", "Task history", output));
|
|
331
|
-
}
|
|
332
|
-
if (action === "scope") {
|
|
333
|
-
const selection = await callService<Record<string, unknown>, import("../../src/domain/task-scope.ts").TaskViewSelection>("tasks.scope", request);
|
|
334
|
-
return text(`Task scope: ${selection.label}`, createPreviewDetails("tasks.scope", "Task scope", selection.label));
|
|
335
|
-
}
|
|
336
|
-
if (action === "active") {
|
|
337
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
|
|
338
|
-
return artifact
|
|
339
|
-
? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
|
|
340
|
-
: text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
|
|
341
|
-
}
|
|
342
|
-
if (action === "focused") {
|
|
343
|
-
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
|
|
344
|
-
return focus
|
|
345
|
-
? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
|
|
346
|
-
: text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
|
|
347
|
-
}
|
|
348
|
-
if (action === "pause" || action === "unpause") {
|
|
349
|
-
const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
|
|
350
|
-
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
|
|
351
|
-
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
|
|
352
|
-
return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
|
|
353
|
-
}
|
|
354
|
-
if (action === "clear_focus") {
|
|
355
|
-
const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
|
|
356
|
-
if (result.cleared) emitTaskFocusEvent({ taskId: null, sessionId: request.session_id as string, status: "cleared" });
|
|
357
|
-
const output = result.cleared ? "Task focus cleared." : "No focused task.";
|
|
358
|
-
return text(output, createPreviewDetails("tasks.clear_focus", "Task focus", output));
|
|
359
|
-
}
|
|
360
|
-
if (action === "graph") {
|
|
361
|
-
const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
|
|
362
|
-
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
363
|
-
const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
364
|
-
const edges = graph.nodes.flatMap((node) => [
|
|
365
|
-
...node.dependencyIds.map((dependencyId) => ({ from: node.task.id, relation: "depends_on", to: dependencyId })),
|
|
366
|
-
...node.childIds.map((childId) => ({ from: node.task.id, relation: "contains", to: childId })),
|
|
367
|
-
]);
|
|
368
|
-
return text(
|
|
369
|
-
`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`,
|
|
370
|
-
createGraphDetails("tasks.graph", graph.nodes.map((node) => node.task), edges),
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
if (action === "plan") {
|
|
374
|
-
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
|
|
375
|
-
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
376
|
-
const titleCounts = new Map<string, number>();
|
|
377
|
-
for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
378
|
-
const nodeLabel = (id: string): string => {
|
|
379
|
-
const node = byId.get(id);
|
|
380
|
-
if (!node) return "unknown task";
|
|
381
|
-
return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
|
|
382
|
-
};
|
|
383
|
-
const lines = plan.layers.flatMap((layer, index) => [
|
|
384
|
-
`Layer ${index + 1}`,
|
|
385
|
-
...layer.map((id) => {
|
|
386
|
-
const node = byId.get(id);
|
|
387
|
-
return ` [${node?.state ?? "unknown"}] ${nodeLabel(id)}`;
|
|
388
|
-
}),
|
|
389
|
-
]);
|
|
390
|
-
if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
|
|
391
|
-
const output = lines.join("\n") || "No tasks in execution plan.";
|
|
392
|
-
return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
|
|
393
|
-
}
|
|
394
|
-
if (action === "context") {
|
|
395
|
-
const summary = await callService<Record<string, unknown>, string | null>("tasks.context", { ...request, verbosity: "full" });
|
|
396
|
-
const output = summary ?? "No open tasks.";
|
|
397
|
-
return text(output, createPreviewDetails("tasks.context", "Task reconciliation context", output));
|
|
398
|
-
}
|
|
399
|
-
if (action === "set_checklist") {
|
|
400
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
|
|
401
|
-
return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
|
|
402
|
-
}
|
|
403
|
-
if (action === "complete") {
|
|
404
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
|
|
405
|
-
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
406
|
-
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
407
|
-
const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
|
|
408
|
-
const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
|
|
409
|
-
const dependencyLabels = await artifactLabelsById(result.blocked.flatMap((entry) => entry.dependencyIds));
|
|
410
|
-
const blocked = result.blocked.length > 0
|
|
411
|
-
? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.map((id) => dependencyLabels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
|
|
412
|
-
: "";
|
|
413
|
-
const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
|
|
414
|
-
return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
|
|
415
|
-
}
|
|
416
|
-
if (action === "run_gates") {
|
|
417
|
-
const [gates, task] = await Promise.all([
|
|
418
|
-
callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request),
|
|
419
|
-
callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.id }),
|
|
420
|
-
]);
|
|
421
|
-
return text(
|
|
422
|
-
gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
|
|
423
|
-
createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", task.title, gates.map((gate) => ({
|
|
424
|
-
passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
|
|
425
|
-
}))),
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
if (action === "event_feed") {
|
|
429
|
-
const page = await callService<Record<string, unknown>, { events: Array<{ id: number; occurredAt: string; taskId: string; type: string }>; nextCursor?: number }>("tasks.event_feed", { cursor: params.cursor, limit: params.limit, event_types: params.event_types });
|
|
430
|
-
const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
|
|
431
|
-
return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
|
|
432
|
-
}
|
|
433
|
-
if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
|
|
434
|
-
const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
|
|
435
|
-
if (action === "release_lease") {
|
|
436
|
-
const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
|
|
437
|
-
const output = released.released ? "Lease released." : "No live lease to release.";
|
|
438
|
-
return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
|
|
439
|
-
}
|
|
440
|
-
const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
|
|
441
|
-
const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
|
|
442
|
-
const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
|
|
443
|
-
return text(output, createPreviewDetails(operation, "Task lease", output));
|
|
444
|
-
}
|
|
445
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
446
|
-
if (trashResult) return trashResult;
|
|
447
|
-
const operations = {
|
|
448
|
-
focus: "tasks.focus",
|
|
449
|
-
start: "tasks.start",
|
|
450
|
-
submit: "tasks.submit",
|
|
451
|
-
reject: "tasks.reject",
|
|
452
|
-
retry: "tasks.retry",
|
|
453
|
-
cancel: "tasks.cancel",
|
|
454
|
-
update: "tasks.update",
|
|
455
|
-
set_scope: "tasks.set_scope",
|
|
456
|
-
assign_project: "tasks.assign_project",
|
|
457
|
-
depend: "tasks.depend",
|
|
458
|
-
undepend: "tasks.undepend",
|
|
459
|
-
contain: "tasks.contain",
|
|
460
|
-
uncontain: "tasks.uncontain",
|
|
461
|
-
} as const;
|
|
462
|
-
const operation = operations[action as keyof typeof operations];
|
|
463
|
-
if (!operation) throw new Error(`unknown tasks action: ${action}`);
|
|
464
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
|
|
465
|
-
if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
|
|
466
|
-
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
467
|
-
} catch (error) {
|
|
468
|
-
throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
|
|
469
|
-
}
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
export function registerNotesTool(pi: ExtensionAPI): void {
|
|
475
|
-
pi.registerTool({
|
|
476
|
-
name: "notes",
|
|
477
|
-
label: "Notes",
|
|
478
|
-
description: "Deferred human-intent inbox. ACTIONS: capture, list, show, history, 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 (or target_name). Archive requires an explicit disposition. history returns this note's own real append-only event log (captured/consumed/promoted/archived), not the generic cross-kind graph.history. PREFER `name` (the note's exact title) over `id` for show/history/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
|
|
479
|
-
parameters: Type.Object({
|
|
480
|
-
action: Type.String(),
|
|
481
|
-
id: Type.Optional(Type.String()),
|
|
482
|
-
name: Type.Optional(Type.String()),
|
|
483
|
-
body: Type.Optional(Type.String()),
|
|
484
|
-
title: Type.Optional(Type.String()),
|
|
485
|
-
status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
|
|
486
|
-
text: Type.Optional(Type.String()),
|
|
487
|
-
limit: Type.Optional(Type.Number()),
|
|
488
|
-
target_id: Type.Optional(Type.String()),
|
|
489
|
-
target_name: Type.Optional(Type.String()),
|
|
490
|
-
disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
|
|
491
|
-
reason: Type.Optional(Type.String()),
|
|
492
|
-
session_id: Type.Optional(Type.String()),
|
|
493
|
-
project_root: Type.Optional(Type.String()),
|
|
494
|
-
}),
|
|
495
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Notes", args, theme); },
|
|
496
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
497
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
498
|
-
try {
|
|
499
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
500
|
-
const action = params.action;
|
|
501
|
-
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
|
|
502
|
-
await resolveNameFields(params, [
|
|
503
|
-
{ nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest },
|
|
504
|
-
// Kind-agnostic: a promotion target can be a task, doc, rule, or skill, so this searches every kind rather than only notes.
|
|
505
|
-
{ nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest },
|
|
506
|
-
]);
|
|
507
|
-
const request = { ...params, ...baseRequest };
|
|
508
|
-
if (action === "capture") {
|
|
509
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
|
|
510
|
-
return text(`Captured note ${artifactLine(artifact)}`, createArtifactDetails("notes.capture", artifact));
|
|
511
|
-
}
|
|
512
|
-
if (action === "list") {
|
|
513
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", request);
|
|
514
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No open notes.", createArtifactListDetails("notes.list", rows));
|
|
515
|
-
}
|
|
516
|
-
if (action === "show") {
|
|
517
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
|
|
518
|
-
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("notes.show", artifact));
|
|
519
|
-
}
|
|
520
|
-
if (action === "history") {
|
|
521
|
-
const page = await callService<Record<string, unknown>, NoteHistoryPage>("notes.history", request);
|
|
522
|
-
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} · ${event.actor}/${event.source}${event.relatedId ? ` · ${event.relatedId}` : ""}${event.disposition ? ` · ${event.disposition}` : ""}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
523
|
-
const output = lines.join("\n") || "No recorded history for this note.";
|
|
524
|
-
return text(output, createPreviewDetails("notes.history", "Note history", output));
|
|
525
|
-
}
|
|
526
|
-
const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
|
|
527
|
-
const operation = operations[action as keyof typeof operations];
|
|
528
|
-
if (!operation) throw new Error(`unknown notes action: ${action}`);
|
|
529
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
|
|
530
|
-
return text(`${action}: ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
|
|
531
|
-
} catch (error) {
|
|
532
|
-
throw new Error(`notes failed: ${error instanceof Error ? error.message : error}`);
|
|
533
|
-
}
|
|
534
|
-
},
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
export function registerDocsTool(pi: ExtensionAPI): void {
|
|
539
|
-
pi.registerTool({
|
|
540
|
-
name: "docs",
|
|
541
|
-
label: "Documents",
|
|
542
|
-
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead. 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.",
|
|
543
|
-
parameters: Type.Object({
|
|
544
|
-
action: Type.String(),
|
|
545
|
-
id: Type.Optional(Type.String()),
|
|
546
|
-
name: Type.Optional(Type.String()),
|
|
547
|
-
title: Type.Optional(Type.String()),
|
|
548
|
-
body: Type.Optional(Type.String()),
|
|
549
|
-
subtype: Type.Optional(Type.String()),
|
|
550
|
-
status: Type.Optional(Type.String()),
|
|
551
|
-
text: Type.Optional(Type.String()),
|
|
552
|
-
limit: Type.Optional(Type.Number()),
|
|
553
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
554
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
555
|
-
template_id: Type.Optional(Type.String()),
|
|
556
|
-
relation: Type.Optional(Type.String()),
|
|
557
|
-
target_id: Type.Optional(Type.String()),
|
|
558
|
-
target_name: Type.Optional(Type.String()),
|
|
559
|
-
project_root: Type.Optional(Type.String()),
|
|
560
|
-
reason: Type.Optional(Type.String()),
|
|
561
|
-
}),
|
|
562
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
|
|
563
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
564
|
-
async execute(_id, rawParams) {
|
|
565
|
-
try {
|
|
566
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
567
|
-
const action = params.action;
|
|
568
|
-
const scopeRequest = { project_root: params.project_root };
|
|
569
|
-
await resolveNameFields(params, [
|
|
570
|
-
{ nameKey: "name", idKey: "id", listOperation: "docs.list", baseRequest: scopeRequest },
|
|
571
|
-
// Kind-agnostic: a link target can be a doc, task, rule, or skill, so this searches every kind rather than only docs.
|
|
572
|
-
{ nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest: scopeRequest },
|
|
573
|
-
]);
|
|
574
|
-
if (action === "create") {
|
|
575
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("docs.create", params);
|
|
576
|
-
return text(`Created document ${artifactLine(artifact)}`, createArtifactDetails("docs.create", artifact));
|
|
577
|
-
}
|
|
578
|
-
if (action === "list") {
|
|
579
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("docs.list", params);
|
|
580
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No documents found.", createArtifactListDetails("docs.list", rows));
|
|
581
|
-
}
|
|
582
|
-
if (action === "show") {
|
|
583
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
|
|
584
|
-
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("docs.show", artifact));
|
|
585
|
-
}
|
|
586
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
587
|
-
if (trashResult) return trashResult;
|
|
588
|
-
const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project", update: "docs.update" } as const;
|
|
589
|
-
const operation = operations[action as keyof typeof operations];
|
|
590
|
-
if (!operation) throw new Error(`unknown docs action: ${action}`);
|
|
591
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
592
|
-
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
593
|
-
} catch (error) {
|
|
594
|
-
throw new Error(`docs failed: ${error instanceof Error ? error.message : error}`);
|
|
595
|
-
}
|
|
596
|
-
},
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
export function registerRulesTool(pi: ExtensionAPI): void {
|
|
601
|
-
pi.registerTool({
|
|
602
|
-
name: "rules",
|
|
603
|
-
label: "Rules",
|
|
604
|
-
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, update, 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. update changes title/body/labels (at least one required); body updates still enforce the same combined condition+action+body context-tax bound as creation, and are refused for a read-only external projection. 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.",
|
|
605
|
-
parameters: Type.Object({
|
|
606
|
-
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
607
|
-
body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
|
|
608
|
-
severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
|
|
609
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
610
|
-
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
|
|
611
|
-
task_name: Type.Optional(Type.String()),
|
|
612
|
-
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
613
|
-
}),
|
|
614
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
|
|
615
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
616
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
617
|
-
try {
|
|
618
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
619
|
-
const action = params.action;
|
|
620
|
-
await resolveNameFields(params, [
|
|
621
|
-
{ nameKey: "name", idKey: "id", listOperation: "rules.list", baseRequest: { project_root: params.project_root } },
|
|
622
|
-
{ nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: { project_root: params.project_root ?? ctx.cwd } },
|
|
623
|
-
]);
|
|
624
|
-
if (action === "create") {
|
|
625
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("rules.create", params);
|
|
626
|
-
return text(`Created rule ${artifactLine(artifact)}`, createArtifactDetails("rules.create", artifact));
|
|
627
|
-
}
|
|
628
|
-
if (action === "list") {
|
|
629
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("rules.list", params);
|
|
630
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No rules found.", createArtifactListDetails("rules.list", rows));
|
|
631
|
-
}
|
|
632
|
-
if (action === "preview") {
|
|
633
|
-
const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
|
|
634
|
-
return text(preview, createPreviewDetails("rules.preview", "Rule preview", preview));
|
|
635
|
-
}
|
|
636
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
637
|
-
if (trashResult) return trashResult;
|
|
638
|
-
const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project", update: "rules.update" } as const;
|
|
639
|
-
const operation = operations[action as keyof typeof operations];
|
|
640
|
-
if (!operation) throw new Error(`unknown rules action: ${action}`);
|
|
641
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
642
|
-
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
643
|
-
} catch (error) {
|
|
644
|
-
throw new Error(`rules failed: ${error instanceof Error ? error.message : error}`);
|
|
645
|
-
}
|
|
646
|
-
},
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
export function registerPlaybooksTool(pi: ExtensionAPI): void {
|
|
651
|
-
pi.registerTool({
|
|
652
|
-
name: "playbooks",
|
|
653
|
-
label: "Playbooks",
|
|
654
|
-
description: "Playbook domain tool -- a completely different beast from the skills tool, not a subtype of it. A Playbook is a trigger and an ordered list of steps an agent reads and follows; it is never mechanically instantiated the way a Skill's artifact-template or workflow blueprint is, and it never composes other Playbooks. ACTIONS: create, list, show, invoke, enable, disable, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. On create, `arguments` declares named inputs the Playbook needs: [{name, description?, required?}] (required defaults true). On invoke, `arguments` supplies known values as {name: value}; invoke renders which declared REQUIRED arguments are still missing and directs you to ask the human for them via the discuss tool with live:true -- never guess or invent a value for a missing required argument. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Playbook to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the playbook's exact title) over `id` -- id is a backend implementation detail, resolved from name automatically.",
|
|
655
|
-
parameters: Type.Object({
|
|
656
|
-
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
657
|
-
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
658
|
-
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
659
|
-
arguments: Type.Optional(Type.Unknown()),
|
|
660
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
661
|
-
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
|
|
662
|
-
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
663
|
-
}),
|
|
664
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Playbooks", args, theme); },
|
|
665
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
666
|
-
async execute(_id, rawParams) {
|
|
667
|
-
try {
|
|
668
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
669
|
-
const action = params.action;
|
|
670
|
-
await resolveNameFields(params, [
|
|
671
|
-
{ nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: { project_root: params.project_root } },
|
|
672
|
-
]);
|
|
673
|
-
if (action === "create" || action === "invoke") normalizeJsonEncodedField(params, "arguments");
|
|
674
|
-
if (action === "create") {
|
|
675
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
|
|
676
|
-
return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
|
|
677
|
-
}
|
|
678
|
-
if (action === "list") {
|
|
679
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", params);
|
|
680
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No playbooks found.", createArtifactListDetails("playbooks.list", rows));
|
|
681
|
-
}
|
|
682
|
-
if (action === "invoke") {
|
|
683
|
-
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", params);
|
|
684
|
-
return text(invocation, createPreviewDetails("playbooks.invoke", "Playbook invocation", invocation));
|
|
685
|
-
}
|
|
686
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
687
|
-
if (trashResult) return trashResult;
|
|
688
|
-
const operations = { show: "playbooks.show", enable: "playbooks.enable", disable: "playbooks.disable", assign_project: "playbooks.assign_project", update: "playbooks.update" } as const;
|
|
689
|
-
const operation = operations[action as keyof typeof operations];
|
|
690
|
-
if (!operation) throw new Error(`unknown playbooks action: ${action}`);
|
|
691
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
692
|
-
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
693
|
-
} catch (error) {
|
|
694
|
-
throw new Error(`playbooks failed: ${error instanceof Error ? error.message : error}`);
|
|
695
|
-
}
|
|
696
|
-
},
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
export function registerSkillsTool(pi: ExtensionAPI): void {
|
|
701
|
-
pi.registerTool({
|
|
702
|
-
name: "skills",
|
|
703
|
-
label: "Skills",
|
|
704
|
-
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, update, 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. update changes title/body/labels (at least one required) and is refused for a read-only external projection. 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.",
|
|
705
|
-
parameters: Type.Object({
|
|
706
|
-
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
707
|
-
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
708
|
-
tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
709
|
-
arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
|
|
710
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
711
|
-
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
712
|
-
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
713
|
-
template_name: Type.Optional(Type.String()),
|
|
714
|
-
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
715
|
-
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
716
|
-
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
717
|
-
}),
|
|
718
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
|
|
719
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
720
|
-
async execute(_id, rawParams, _signal, _onUpdate, ctx) {
|
|
721
|
-
try {
|
|
722
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
723
|
-
const action = params.action;
|
|
724
|
-
const request = { ...params, project_root: params.project_root ?? ctx.cwd };
|
|
725
|
-
await resolveNameFields(params, [
|
|
726
|
-
{ nameKey: "name", idKey: "id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
|
|
727
|
-
{ nameKey: "template_name", idKey: "template_id", listOperation: "skills.list", baseRequest: { project_root: params.project_root } },
|
|
728
|
-
]);
|
|
729
|
-
if (action === "create" || action === "create_template") {
|
|
730
|
-
const operation = action === "create" ? "skills.create" : "skills.create_template";
|
|
731
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
732
|
-
return text(`Created skill ${artifactLine(artifact)}`, createArtifactDetails(operation, artifact));
|
|
733
|
-
}
|
|
734
|
-
if (action === "list") {
|
|
735
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("skills.list", params);
|
|
736
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No skills found.", createArtifactListDetails("skills.list", rows));
|
|
737
|
-
}
|
|
738
|
-
if (action === "invoke") {
|
|
739
|
-
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
|
|
740
|
-
return text(invocation, createPreviewDetails("skills.invoke", "Skill invocation", invocation));
|
|
741
|
-
}
|
|
742
|
-
if (action === "run") {
|
|
743
|
-
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
|
|
744
|
-
const runTitleCounts = new Map<string, number>();
|
|
745
|
-
for (const node of run.execution.nodes) runTitleCounts.set(node.title, (runTitleCounts.get(node.title) ?? 0) + 1);
|
|
746
|
-
const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
|
|
747
|
-
? ` [${node.state}] ${node.title} (${node.id})`
|
|
748
|
-
: ` [${node.state}] ${node.title}`).join("\n");
|
|
749
|
-
const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
|
|
750
|
-
const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
751
|
-
const createdLabels = await artifactLabelsById([...run.created.docs, ...run.created.rules]);
|
|
752
|
-
return text([
|
|
753
|
-
`Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
|
|
754
|
-
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
755
|
-
`Context docs: ${run.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
756
|
-
`Scoped rules: ${run.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
757
|
-
...(execution ? ["Execution:", execution] : []),
|
|
758
|
-
].join("\n"), createInvocationDetails("skills.run", run.runId, {
|
|
759
|
-
tasks: run.created.tasks,
|
|
760
|
-
docs: run.created.docs,
|
|
761
|
-
rules: run.created.rules,
|
|
762
|
-
roots: run.rootTaskIds,
|
|
763
|
-
}));
|
|
764
|
-
}
|
|
765
|
-
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
766
|
-
if (trashResult) return trashResult;
|
|
767
|
-
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
|
|
768
|
-
const operation = operations[action as keyof typeof operations];
|
|
769
|
-
if (!operation) throw new Error(`unknown skills action: ${action}`);
|
|
770
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
|
|
771
|
-
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
772
|
-
} catch (error) {
|
|
773
|
-
throw new Error(`skills failed: ${error instanceof Error ? error.message : error}`);
|
|
774
|
-
}
|
|
775
|
-
},
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
780
|
-
pi.registerTool({
|
|
781
|
-
name: "discuss",
|
|
782
|
-
label: "Discuss",
|
|
783
|
-
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. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. The live picker docks in the input area itself (falls back to a plain text prompt if unsupported in the current UI mode). 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.",
|
|
784
|
-
parameters: Type.Object({
|
|
785
|
-
action: Type.String(),
|
|
786
|
-
id: Type.Optional(Type.String()),
|
|
787
|
-
name: Type.Optional(Type.String()),
|
|
788
|
-
title: Type.Optional(Type.String()),
|
|
789
|
-
actor: Type.Optional(Type.String()),
|
|
790
|
-
content: Type.Optional(Type.String()),
|
|
791
|
-
body: Type.Optional(Type.String()),
|
|
792
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
793
|
-
blocks_task_ids: Type.Optional(Type.Array(Type.String())),
|
|
794
|
-
blocks_task_names: Type.Optional(Type.Array(Type.String())),
|
|
795
|
-
task_id: Type.Optional(Type.String()),
|
|
796
|
-
task_name: Type.Optional(Type.String()),
|
|
797
|
-
reason: Type.Optional(Type.String()),
|
|
798
|
-
settlement: Type.Optional(Type.String()),
|
|
799
|
-
state: Type.Optional(Type.String()),
|
|
800
|
-
after_round: Type.Optional(Type.Number()),
|
|
801
|
-
limit: Type.Optional(Type.Number()),
|
|
802
|
-
options: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })]))),
|
|
803
|
-
options_mode: Type.Optional(Type.String()),
|
|
804
|
-
selected: Type.Optional(Type.Array(Type.String())),
|
|
805
|
-
live: Type.Optional(Type.Boolean()),
|
|
806
|
-
}),
|
|
807
|
-
// Blocks other tool calls in the same assistant turn until live:true's human answer comes
|
|
808
|
-
// back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
|
|
809
|
-
// bash/edit/write and let those run before the human sees the prompt.
|
|
810
|
-
executionMode: "sequential",
|
|
811
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
|
|
812
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
813
|
-
async execute(_id, rawParams, signal, onUpdate, ctx) {
|
|
814
|
-
try {
|
|
815
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
816
|
-
const action = params.action;
|
|
817
|
-
const taskScope = { project_root: ctx.cwd };
|
|
818
|
-
await resolveNameFields(params, [
|
|
819
|
-
{ nameKey: "name", idKey: "id", listOperation: "discuss.list", baseRequest: {} },
|
|
820
|
-
{ nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
|
|
821
|
-
]);
|
|
822
|
-
await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
|
|
823
|
-
if (action === "open" || action === "reply") {
|
|
824
|
-
normalizeDiscussOptions(params);
|
|
825
|
-
const operation = action === "open" ? "discuss.open" : "discuss.reply";
|
|
826
|
-
const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
|
|
827
|
-
const fallback = action === "open"
|
|
828
|
-
? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
|
|
829
|
-
: text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
|
|
830
|
-
if (params.live !== true) return fallback;
|
|
831
|
-
const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
|
|
832
|
-
if (!answer) return fallback;
|
|
833
|
-
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
834
|
-
id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
|
|
835
|
-
});
|
|
836
|
-
return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
|
|
837
|
-
}
|
|
838
|
-
if (action === "block" || action === "unblock") {
|
|
839
|
-
const operation = action === "block" ? "discuss.block" : "discuss.unblock";
|
|
840
|
-
const [outcome, discussionAndRounds, task] = await Promise.all([
|
|
841
|
-
callService<Record<string, unknown>, { blocked?: boolean; unblocked?: boolean }>(operation, params),
|
|
842
|
-
callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: params.id }),
|
|
843
|
-
callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
|
|
844
|
-
]);
|
|
845
|
-
const discussion = discussionAndRounds.discussion;
|
|
846
|
-
const message = action === "unblock" && !outcome.unblocked
|
|
847
|
-
? "No such blocking relationship."
|
|
848
|
-
: `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
|
|
849
|
-
return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
|
|
850
|
-
}
|
|
851
|
-
if (action === "show") {
|
|
852
|
-
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
|
|
853
|
-
const rounds = result.rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n");
|
|
854
|
-
return text(`${artifactLine(result.discussion)}\n\n${rounds}`, createArtifactDetails("discuss.show", result.discussion));
|
|
855
|
-
}
|
|
856
|
-
if (action === "rounds") {
|
|
857
|
-
const rounds = await callService<Record<string, unknown>, DiscussionRound[]>("discuss.rounds", params);
|
|
858
|
-
const output = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || "No rounds.";
|
|
859
|
-
return text(output, createPreviewDetails("discuss.rounds", "Discussion rounds", output));
|
|
860
|
-
}
|
|
861
|
-
if (action === "list") {
|
|
862
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
|
|
863
|
-
return text(rows.length ? artifactLines(rows).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
|
|
864
|
-
}
|
|
865
|
-
const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
|
|
866
|
-
const operation = operations[action as keyof typeof operations];
|
|
867
|
-
if (!operation) throw new Error(`unknown discuss action: ${action}`);
|
|
868
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
869
|
-
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
870
|
-
} catch (error) {
|
|
871
|
-
throw new Error(`discuss failed: ${error instanceof Error ? error.message : error}`);
|
|
872
|
-
}
|
|
873
|
-
},
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
|
|
878
|
-
export function registerDomainTools(pi: ExtensionAPI): void {
|
|
879
|
-
registerTasksTool(pi);
|
|
880
|
-
registerNotesTool(pi);
|
|
881
|
-
registerDocsTool(pi);
|
|
882
|
-
registerRulesTool(pi);
|
|
883
|
-
registerPlaybooksTool(pi);
|
|
884
|
-
registerSkillsTool(pi);
|
|
885
|
-
registerDiscussTool(pi);
|
|
886
|
-
}
|