@danypops/pi-papyrus 0.54.0 → 0.54.2
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/artifact/artifact-browser.ts +6 -2
- package/extension/src/tool-rendering/render-model/artifact.ts +82 -0
- package/extension/src/tool-rendering/render-model/discussion.ts +49 -0
- package/extension/src/tool-rendering/render-model/execution-plan.ts +88 -0
- package/extension/src/tool-rendering/render-model/gate-run.ts +55 -0
- package/extension/src/tool-rendering/render-model/graph.ts +43 -0
- package/extension/src/tool-rendering/render-model/index.ts +235 -0
- package/extension/src/tool-rendering/render-model/lease.ts +40 -0
- package/extension/src/tool-rendering/render-model/misc.ts +45 -0
- package/extension/src/tool-rendering/render-model/playbook.ts +107 -0
- package/extension/src/tool-rendering/render-model/shared.ts +163 -0
- package/extension/src/tool-rendering/render-model/task-completion.ts +92 -0
- package/extension/src/tool-rendering/render-model.ts +17 -827
- package/extension/src/tools/renderers/discussion.ts +92 -0
- package/extension/src/tools/renderers/index.ts +236 -0
- package/extension/src/tools/renderers/lease.ts +57 -0
- package/extension/src/tools/renderers/playbook.ts +69 -0
- package/extension/src/tools/renderers/shared.ts +94 -0
- package/extension/src/tools/renderers/task-completion.ts +98 -0
- package/extension/src/tools/renderers/task-execution.ts +79 -0
- package/extension/src/tools/vehicle-artifact-renderers.ts +14 -669
- package/package.json +2 -2
|
@@ -1,670 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import { TOOL_COLLAPSED_ROW_LIMIT, TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS } from "@danypops/papyrus";
|
|
17
|
-
import type { PiVehicleInvocationRequest, PiVehiclePresentationContract, VehicleToolRenderers } from "@danypops/vehicle-client-pi";
|
|
18
|
-
import { expandHint } from "@danypops/vehicle-client-pi/expand-hint";
|
|
19
|
-
import { renderVehicleCall, renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
|
|
20
|
-
import type { JsonValue, VehicleOperationDescriptor } from "@danypops/vehicle-core";
|
|
21
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
23
|
-
import {
|
|
24
|
-
buildDetailLines,
|
|
25
|
-
type DagEdge,
|
|
26
|
-
type DagNode,
|
|
27
|
-
DagView,
|
|
28
|
-
type DetailField,
|
|
29
|
-
type DetailSection,
|
|
30
|
-
statelessComponent,
|
|
31
|
-
} from "malevich-tui-components";
|
|
32
|
-
import { ArtifactCard, detailViewTheme, measure, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
|
|
33
|
-
import { ArtifactListCard } from "../tool-rendering/artifact-list.ts";
|
|
34
|
-
import {
|
|
35
|
-
type ArtifactFocusAnnotation,
|
|
36
|
-
createArtifactDetails,
|
|
37
|
-
createArtifactListDetails,
|
|
38
|
-
createDiscussionDetails,
|
|
39
|
-
createExecutionPlanDetails,
|
|
40
|
-
createLeaseDetails,
|
|
41
|
-
createNoFocusDetails,
|
|
42
|
-
createPlaybookInvocationDetails,
|
|
43
|
-
createPlaybookMissingArgumentsDetails,
|
|
44
|
-
createPreviewDetails,
|
|
45
|
-
createTaskCompletionDetails,
|
|
46
|
-
parsePapyrusToolDetails,
|
|
47
|
-
} from "../tool-rendering/render-model.ts";
|
|
48
|
-
import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
|
|
49
|
-
|
|
50
|
-
/** Every field an Artifact and its lean list-default ArtifactSummary (tasks.list/docs.list/
|
|
51
|
-
* rules.list/playbooks.list without full:true -- see summarizeArtifact()) both always carry. */
|
|
52
|
-
function hasArtifactCoreFields(value: unknown): value is Omit<Artifact, "body" | "extra"> {
|
|
53
|
-
if (typeof value !== "object" || value === null) return false;
|
|
54
|
-
const row = value as Record<string, unknown>;
|
|
55
|
-
return (
|
|
56
|
-
typeof row.id === "string" &&
|
|
57
|
-
typeof row.kind === "string" &&
|
|
58
|
-
typeof row.title === "string" &&
|
|
59
|
-
typeof row.status === "string" &&
|
|
60
|
-
typeof row.subtype === "string" &&
|
|
61
|
-
Array.isArray(row.labels) &&
|
|
62
|
-
typeof row.created_at === "string" &&
|
|
63
|
-
typeof row.updated_at === "string"
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isArtifact(value: unknown): value is Artifact {
|
|
68
|
-
return hasArtifactCoreFields(value) && typeof (value as Record<string, unknown>).body === "string";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Regression (real, live-observed): tasks.list's own documented default ("Returns a lean
|
|
73
|
-
* summary (no body/extra) unless full: true is passed") returns ArtifactSummary rows, which
|
|
74
|
-
* omit body entirely. createArtifactListDetails/artifactSummary (render-model.ts) never read
|
|
75
|
-
* .body for list rendering -- only single-artifact createArtifactDetails does -- so requiring
|
|
76
|
-
* body here (matching isArtifact) silently fell every default (lean, the common case) list
|
|
77
|
-
* call for tasks.list/docs.list/rules.list/playbooks.list through to the generic raw Vehicle
|
|
78
|
-
* table renderer instead of the curated ArtifactListCard.
|
|
79
|
-
*/
|
|
80
|
-
function isArtifactArray(value: unknown): value is Artifact[] {
|
|
81
|
-
return Array.isArray(value) && value.every(hasArtifactCoreFields);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** tasks.focused/tasks.pause/tasks.unpause's own wrapper shape -- an Artifact
|
|
85
|
-
* plus Task Focus's separate active/paused dimension. Detected the same
|
|
86
|
-
* name-independent, shape-based way as isArtifact/isArtifactArray above. */
|
|
87
|
-
interface TaskFocusOutput {
|
|
88
|
-
artifact: Artifact;
|
|
89
|
-
status: string;
|
|
90
|
-
updatedAt: string;
|
|
91
|
-
pauseReason?: string;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function isTaskFocus(value: unknown): value is TaskFocusOutput {
|
|
95
|
-
if (typeof value !== "object" || value === null) return false;
|
|
96
|
-
const row = value as Record<string, unknown>;
|
|
97
|
-
return (
|
|
98
|
-
isArtifact(row.artifact) &&
|
|
99
|
-
typeof row.status === "string" &&
|
|
100
|
-
typeof row.updatedAt === "string" &&
|
|
101
|
-
(row.pauseReason === undefined || typeof row.pauseReason === "string")
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function focusAnnotation(output: TaskFocusOutput): ArtifactFocusAnnotation {
|
|
106
|
-
return { status: output.status, updatedAt: output.updatedAt, ...(output.pauseReason ? { pauseReason: output.pauseReason } : {}) };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function renderNoFocusedTask(theme: Theme): Component {
|
|
110
|
-
return new Text(theme.fg("dim", "No focused task."), 0, 0);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** tasks.plan's own TaskExecutionPlan shape (projectTaskExecution) -- a
|
|
114
|
-
* genuinely structured topological-execution view, never artifact-shaped,
|
|
115
|
-
* detected the same name-independent way as the others in this file. */
|
|
116
|
-
interface TaskExecutionNodeOutput {
|
|
117
|
-
id: string;
|
|
118
|
-
title: string;
|
|
119
|
-
status: string;
|
|
120
|
-
active: boolean;
|
|
121
|
-
state: string;
|
|
122
|
-
layer: number | null;
|
|
123
|
-
prerequisiteIds: string[];
|
|
124
|
-
successorIds: string[];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface TaskExecutionPlanOutput {
|
|
128
|
-
nodes: TaskExecutionNodeOutput[];
|
|
129
|
-
layers: string[][];
|
|
130
|
-
cycleIds: string[];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isTaskExecutionNode(value: unknown): value is TaskExecutionNodeOutput {
|
|
134
|
-
if (typeof value !== "object" || value === null) return false;
|
|
135
|
-
const row = value as Record<string, unknown>;
|
|
136
|
-
return (
|
|
137
|
-
typeof row.id === "string" &&
|
|
138
|
-
typeof row.title === "string" &&
|
|
139
|
-
typeof row.status === "string" &&
|
|
140
|
-
typeof row.active === "boolean" &&
|
|
141
|
-
typeof row.state === "string" &&
|
|
142
|
-
(row.layer === null || typeof row.layer === "number") &&
|
|
143
|
-
Array.isArray(row.prerequisiteIds) &&
|
|
144
|
-
Array.isArray(row.successorIds)
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isTaskExecutionPlan(value: unknown): value is TaskExecutionPlanOutput {
|
|
149
|
-
if (typeof value !== "object" || value === null) return false;
|
|
150
|
-
const row = value as Record<string, unknown>;
|
|
151
|
-
return (
|
|
152
|
-
Array.isArray(row.nodes) &&
|
|
153
|
-
row.nodes.every(isTaskExecutionNode) &&
|
|
154
|
-
Array.isArray(row.layers) &&
|
|
155
|
-
row.layers.every((layer) => Array.isArray(layer) && layer.every((id) => typeof id === "string")) &&
|
|
156
|
-
Array.isArray(row.cycleIds) &&
|
|
157
|
-
row.cycleIds.every((id) => typeof id === "string")
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function dagViewFromExecutionPlan(plan: TaskExecutionPlanOutput, theme: Theme, expanded: boolean): DagView {
|
|
162
|
-
const nodes: DagNode[] = plan.nodes.map((node) => ({
|
|
163
|
-
id: node.id,
|
|
164
|
-
label: `${theme.fg(statusColor(node.state), statusGlyph(node.state))} ${theme.fg("text", node.title)}`,
|
|
165
|
-
}));
|
|
166
|
-
const edges: DagEdge[] = plan.nodes.flatMap((node) => node.prerequisiteIds.map((from) => ({ from, to: node.id })));
|
|
167
|
-
return new DagView({
|
|
168
|
-
layers: plan.layers,
|
|
169
|
-
nodes,
|
|
170
|
-
edges,
|
|
171
|
-
cycleIds: plan.cycleIds,
|
|
172
|
-
defaultStyle: (s) => theme.fg("text", s),
|
|
173
|
-
edgeStyle: (s) => theme.fg("dim", s),
|
|
174
|
-
layerHeaderStyle: (s) => theme.fg("toolTitle", theme.bold(s)),
|
|
175
|
-
cycleHeaderStyle: (s) => theme.fg("error", theme.bold(s)),
|
|
176
|
-
expanded,
|
|
177
|
-
visibleNodeCount: TOOL_COLLAPSED_ROW_LIMIT,
|
|
178
|
-
moreLine: (hiddenCount) => theme.fg("dim", `${hiddenCount} more · ${expandHint()}`),
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function renderTaskExecutionPlan(plan: TaskExecutionPlanOutput, theme: Theme, expanded: boolean): Component {
|
|
183
|
-
return dagViewFromExecutionPlan(plan, theme, expanded);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** playbooks.invoke's own PlaybookInvocationResult shape -- a materialized execution plan
|
|
187
|
-
* (same shape tasks.plan renders) plus which docs/rules/tasks were created and which one to
|
|
188
|
-
* focus. Detected the same name-independent, shape-based way as the others in this file. */
|
|
189
|
-
interface PlaybookInvocationResultOutput {
|
|
190
|
-
playbookId: string;
|
|
191
|
-
runId: string;
|
|
192
|
-
created: { docs: string[]; rules: string[]; tasks: string[] };
|
|
193
|
-
rootTaskIds: string[];
|
|
194
|
-
entryTaskId: string;
|
|
195
|
-
execution: TaskExecutionPlanOutput;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
interface PlaybookMissingArgumentsOutput {
|
|
199
|
-
playbookId: string;
|
|
200
|
-
missingArguments: string[];
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function isPlaybookInvocationResult(value: unknown): value is PlaybookInvocationResultOutput {
|
|
204
|
-
if (typeof value !== "object" || value === null) return false;
|
|
205
|
-
const row = value as Record<string, unknown>;
|
|
206
|
-
return (
|
|
207
|
-
typeof row.playbookId === "string" &&
|
|
208
|
-
typeof row.runId === "string" &&
|
|
209
|
-
typeof row.entryTaskId === "string" &&
|
|
210
|
-
Array.isArray(row.rootTaskIds) &&
|
|
211
|
-
typeof row.created === "object" &&
|
|
212
|
-
row.created !== null &&
|
|
213
|
-
Array.isArray((row.created as Record<string, unknown>).docs) &&
|
|
214
|
-
Array.isArray((row.created as Record<string, unknown>).rules) &&
|
|
215
|
-
Array.isArray((row.created as Record<string, unknown>).tasks) &&
|
|
216
|
-
isTaskExecutionPlan(row.execution)
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function isPlaybookMissingArguments(value: unknown): value is PlaybookMissingArgumentsOutput {
|
|
221
|
-
if (typeof value !== "object" || value === null) return false;
|
|
222
|
-
const row = value as Record<string, unknown>;
|
|
223
|
-
return (
|
|
224
|
-
typeof row.playbookId === "string" &&
|
|
225
|
-
Array.isArray(row.missingArguments) &&
|
|
226
|
-
row.missingArguments.every((entry) => typeof entry === "string")
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function renderPlaybookInvocationResult(result: PlaybookInvocationResultOutput, theme: Theme, expanded: boolean): Component {
|
|
231
|
-
const dag = dagViewFromExecutionPlan(result.execution, theme, expanded);
|
|
232
|
-
const counts = [
|
|
233
|
-
["task", result.created.tasks.length],
|
|
234
|
-
["rule", result.created.rules.length],
|
|
235
|
-
["doc", result.created.docs.length],
|
|
236
|
-
] as const;
|
|
237
|
-
const summary = counts
|
|
238
|
-
.filter(([, count]) => count > 0)
|
|
239
|
-
.map(([noun, count]) => `${count} ${noun}${count === 1 ? "" : "s"}`)
|
|
240
|
-
.join(", ");
|
|
241
|
-
return {
|
|
242
|
-
render: (width: number) => [...dag.render(width), truncateToWidth(theme.fg("dim", summary || "Nothing created."), width)],
|
|
243
|
-
invalidate: () => dag.invalidate(),
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function renderPlaybookMissingArguments(result: PlaybookMissingArgumentsOutput, theme: Theme): Component {
|
|
248
|
-
const line = theme.fg("warning", `Missing required argument(s): ${result.missingArguments.join(", ")}`);
|
|
249
|
-
return new Text(line, 0, 0);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** A Discussion round -- discuss.open/reply/show/rounds' own transcript entry. Detected the
|
|
253
|
-
* same name-independent, shape-based way as the others in this file. */
|
|
254
|
-
interface DiscussionRoundOutput {
|
|
255
|
-
roundNumber: number;
|
|
256
|
-
actor: string;
|
|
257
|
-
content: string;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function isDiscussionRound(value: unknown): value is DiscussionRoundOutput {
|
|
261
|
-
if (typeof value !== "object" || value === null) return false;
|
|
262
|
-
const row = value as Record<string, unknown>;
|
|
263
|
-
return typeof row.roundNumber === "number" && typeof row.actor === "string" && typeof row.content === "string";
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function isDiscussionRoundArray(value: unknown): value is DiscussionRoundOutput[] {
|
|
267
|
-
return Array.isArray(value) && value.every(isDiscussionRound);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
interface DiscussionAndRoundsOutput {
|
|
271
|
-
discussion: Artifact;
|
|
272
|
-
rounds: DiscussionRoundOutput[];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** What renderDiscussionAndRounds actually reads -- satisfied by both a raw Artifact (the live duck-typed output path) and the leaner projected ToolArtifactSummary (the typed-DTO path), with no cast needed at either call site. */
|
|
276
|
-
interface RenderableDiscussionParent {
|
|
277
|
-
title: string;
|
|
278
|
-
status: string;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function isDiscussionAndRounds(value: unknown): value is DiscussionAndRoundsOutput {
|
|
282
|
-
if (typeof value !== "object" || value === null) return false;
|
|
283
|
-
const row = value as Record<string, unknown>;
|
|
284
|
-
return isArtifact(row.discussion) && isDiscussionRoundArray(row.rounds);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
interface DiscussionRoundsOnlyOutput {
|
|
288
|
-
rounds: DiscussionRoundOutput[];
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function isDiscussionRoundsOnly(value: unknown): value is DiscussionRoundsOnlyOutput {
|
|
292
|
-
if (typeof value !== "object" || value === null) return false;
|
|
293
|
-
const row = value as Record<string, unknown>;
|
|
294
|
-
return row.discussion === undefined && isDiscussionRoundArray(row.rounds);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
interface DiscussionListOutput {
|
|
298
|
-
discussions: Artifact[];
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function isDiscussionListOutput(value: unknown): value is DiscussionListOutput {
|
|
302
|
-
if (typeof value !== "object" || value === null) return false;
|
|
303
|
-
const row = value as Record<string, unknown>;
|
|
304
|
-
return isArtifactArray(row.discussions);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function roundsSection(rounds: readonly DiscussionRoundOutput[]): DetailSection {
|
|
308
|
-
return {
|
|
309
|
-
heading: `Rounds (${rounds.length}):`,
|
|
310
|
-
items: rounds.map((round) => ({ byline: `${round.actor} · round ${round.roundNumber}`, body: round.content })),
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function renderDiscussionAndRounds(
|
|
315
|
-
output: { discussion: RenderableDiscussionParent; rounds: readonly DiscussionRoundOutput[] },
|
|
316
|
-
theme: Theme,
|
|
317
|
-
expanded: boolean,
|
|
318
|
-
): Component {
|
|
319
|
-
const discussion = output.discussion;
|
|
320
|
-
return statelessComponent((width) => {
|
|
321
|
-
const safeWidth = Math.max(1, width);
|
|
322
|
-
const fields: DetailField[] = [
|
|
323
|
-
{ label: "Title", value: discussion.title },
|
|
324
|
-
{ label: "Status", value: theme.fg(statusColor(discussion.status), `${statusGlyph(discussion.status)} ${discussion.status}`) },
|
|
325
|
-
];
|
|
326
|
-
const sections: DetailSection[] = expanded && output.rounds.length > 0 ? [roundsSection(output.rounds)] : [];
|
|
327
|
-
const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
|
|
328
|
-
if (!expanded && output.rounds.length > 0) {
|
|
329
|
-
const count = output.rounds.length;
|
|
330
|
-
lines.push(truncateToWidth(theme.fg("dim", `${count} round${count === 1 ? "" : "s"} · ${expandHint()}`), safeWidth));
|
|
331
|
-
}
|
|
332
|
-
return lines;
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function renderDiscussionRoundsOnly(output: DiscussionRoundsOnlyOutput, theme: Theme): Component {
|
|
337
|
-
return statelessComponent((width) => {
|
|
338
|
-
const sections: DetailSection[] = output.rounds.length > 0 ? [roundsSection(output.rounds)] : [{ lines: ["No rounds."] }];
|
|
339
|
-
return buildDetailLines(Math.max(1, width), { sections, theme: detailViewTheme(theme), measure });
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** tasks.complete's own TaskCompletion shape -- a completed (or rejected) task plus its own
|
|
344
|
-
* gate/checklist proof run and any dependents still left blocked. Detected the same
|
|
345
|
-
* name-independent, shape-based way as the others in this file. */
|
|
346
|
-
interface TaskGateResultOutput {
|
|
347
|
-
gate: unknown;
|
|
348
|
-
passed: boolean;
|
|
349
|
-
output: string;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
interface TaskChecklistReviewOutput {
|
|
353
|
-
item: string;
|
|
354
|
-
accepted: boolean;
|
|
355
|
-
reason?: string;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
interface TaskBlockageOutput {
|
|
359
|
-
artifact: Artifact;
|
|
360
|
-
dependencyIds: string[];
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
interface TaskCompletionOutput {
|
|
364
|
-
artifact: Artifact;
|
|
365
|
-
gates: TaskGateResultOutput[];
|
|
366
|
-
checklist: TaskChecklistReviewOutput[];
|
|
367
|
-
completed: boolean;
|
|
368
|
-
focused: Artifact | null;
|
|
369
|
-
blocked: TaskBlockageOutput[];
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** What renderTaskCompletion actually reads -- satisfied by both the raw duck-typed TaskCompletionOutput and the leaner projected TaskCompletionToolDetails. */
|
|
373
|
-
interface RenderableTaskCompletion {
|
|
374
|
-
artifact: RenderableDiscussionParent;
|
|
375
|
-
gates: readonly { passed: boolean; output: string }[];
|
|
376
|
-
checklist: readonly TaskChecklistReviewOutput[];
|
|
377
|
-
completed: boolean;
|
|
378
|
-
focused?: RenderableDiscussionParent | null;
|
|
379
|
-
blocked: readonly { artifact: RenderableDiscussionParent; dependencyIds: readonly string[] }[];
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function isTaskCompletion(value: unknown): value is TaskCompletionOutput {
|
|
383
|
-
if (typeof value !== "object" || value === null) return false;
|
|
384
|
-
const row = value as Record<string, unknown>;
|
|
385
|
-
return (
|
|
386
|
-
isArtifact(row.artifact) &&
|
|
387
|
-
Array.isArray(row.gates) &&
|
|
388
|
-
Array.isArray(row.checklist) &&
|
|
389
|
-
typeof row.completed === "boolean" &&
|
|
390
|
-
(row.focused === null || isArtifact(row.focused)) &&
|
|
391
|
-
Array.isArray(row.blocked)
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function renderTaskCompletion(result: RenderableTaskCompletion, theme: Theme, expanded: boolean): Component {
|
|
396
|
-
const task = result.artifact;
|
|
397
|
-
return statelessComponent((width) => {
|
|
398
|
-
const safeWidth = Math.max(1, width);
|
|
399
|
-
const fields: DetailField[] = [
|
|
400
|
-
{ label: "Title", value: task.title },
|
|
401
|
-
{ label: "Status", value: theme.fg(statusColor(task.status), `${statusGlyph(task.status)} ${task.status}`) },
|
|
402
|
-
];
|
|
403
|
-
const sections: DetailSection[] = [];
|
|
404
|
-
if (result.gates.length > 0) {
|
|
405
|
-
sections.push({
|
|
406
|
-
heading: "Gates:",
|
|
407
|
-
lines: result.gates.map((gate) => theme.fg(gate.passed ? "success" : "error", `${gate.passed ? "✓" : "✗"} ${gate.output}`)),
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
if (result.checklist.length > 0) {
|
|
411
|
-
sections.push({
|
|
412
|
-
heading: "Checklist:",
|
|
413
|
-
lines: result.checklist.map((entry) =>
|
|
414
|
-
theme.fg(
|
|
415
|
-
entry.accepted ? "success" : "error",
|
|
416
|
-
`${entry.accepted ? "✓" : "✗"} ${entry.item}${entry.reason ? ` — ${entry.reason}` : ""}`,
|
|
417
|
-
),
|
|
418
|
-
),
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
if (result.blocked.length > 0) {
|
|
422
|
-
sections.push({
|
|
423
|
-
heading: "Still blocked:",
|
|
424
|
-
lines: result.blocked.map((entry) => theme.fg("warning", `◼ ${entry.artifact.title}`)),
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
|
|
428
|
-
if (result.focused && expanded) {
|
|
429
|
-
lines.push(truncateToWidth(theme.fg("accent", `▶ focus ${result.focused.title}`), safeWidth));
|
|
430
|
-
}
|
|
431
|
-
return lines;
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** tasks.claim/heartbeat_lease/release_lease/lease's own name-first view. Detected the same
|
|
436
|
-
* name-independent, shape-based way as the others in this file. */
|
|
437
|
-
interface TaskLeaseViewOutput {
|
|
438
|
-
taskName: string;
|
|
439
|
-
taskTitle: string;
|
|
440
|
-
owner: string;
|
|
441
|
-
token: string;
|
|
442
|
-
claimedAt: string;
|
|
443
|
-
leaseExpiresAt: string;
|
|
444
|
-
heartbeatAt?: string;
|
|
445
|
-
note?: string;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function isTaskLeaseView(value: unknown): value is TaskLeaseViewOutput {
|
|
449
|
-
if (typeof value !== "object" || value === null) return false;
|
|
450
|
-
const row = value as Record<string, unknown>;
|
|
451
|
-
return (
|
|
452
|
-
typeof row.taskName === "string" &&
|
|
453
|
-
typeof row.taskTitle === "string" &&
|
|
454
|
-
typeof row.owner === "string" &&
|
|
455
|
-
typeof row.token === "string" &&
|
|
456
|
-
typeof row.claimedAt === "string" &&
|
|
457
|
-
typeof row.leaseExpiresAt === "string" &&
|
|
458
|
-
(row.heartbeatAt === undefined || typeof row.heartbeatAt === "string") &&
|
|
459
|
-
(row.note === undefined || typeof row.note === "string")
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/** Renders a lease's own safe fields -- never the raw token, which the model channel (not this persisted, human-facing one) carries for a later heartbeat/release call. */
|
|
464
|
-
function renderLease(
|
|
465
|
-
lease: {
|
|
466
|
-
taskName: string;
|
|
467
|
-
taskTitle: string;
|
|
468
|
-
owner: string;
|
|
469
|
-
claimedAt: string;
|
|
470
|
-
leaseExpiresAt: string;
|
|
471
|
-
heartbeatAt?: string;
|
|
472
|
-
note?: string;
|
|
473
|
-
},
|
|
474
|
-
theme: Theme,
|
|
475
|
-
): Component {
|
|
476
|
-
return statelessComponent((width) => {
|
|
477
|
-
const fields: DetailField[] = [
|
|
478
|
-
{ label: "Task", value: `${lease.taskName} \u2014 ${lease.taskTitle}` },
|
|
479
|
-
{ label: "Owner", value: lease.owner },
|
|
480
|
-
{ label: "Expires", value: lease.leaseExpiresAt },
|
|
481
|
-
...(lease.heartbeatAt ? [{ label: "Last heartbeat", value: lease.heartbeatAt }] : []),
|
|
482
|
-
...(lease.note ? [{ label: "Note", value: lease.note }] : []),
|
|
483
|
-
];
|
|
484
|
-
return buildDetailLines(Math.max(1, width), { fields, alignFields: true, theme: detailViewTheme(theme), measure });
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** Deliberately does not catch: a value JSON.stringify can't serialize (e.g. a circular
|
|
489
|
-
* reference) has no safe textual fallback, so this propagates and the projector's own caller
|
|
490
|
-
* (invokeVehicleOperation) fails the whole call closed rather than persisting a placeholder --
|
|
491
|
-
* the same "never silently substitute raw/unsafe output" contract every other projection
|
|
492
|
-
* failure already carries. */
|
|
493
|
-
function boundedJsonPreview(value: unknown): string {
|
|
494
|
-
const text = JSON.stringify(value, null, 2);
|
|
495
|
-
return typeof text === "string" ? text : String(value);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor): VehicleToolRenderers {
|
|
499
|
-
return {
|
|
500
|
-
// Pure pass-through to the generic renderer -- the only reason this exists at all is
|
|
501
|
-
// the /reload investigation (papyrus task 4930cd9b): its absence from the diagnostic
|
|
502
|
-
// log for a real invocation (see onInvoked in vehicle-notes-client.ts) is itself
|
|
503
|
-
// evidence Pi never found ANY renderer -- ours or vehicle-client-pi's generic default
|
|
504
|
-
// -- for that specific tool call, distinct from this renderer running and choosing
|
|
505
|
-
// the generic path internally (which DOES show up here).
|
|
506
|
-
renderCall(args, theme, context) {
|
|
507
|
-
recordRenderDiagnostic({ event: "render-call-invoked", operation: descriptor.name });
|
|
508
|
-
return renderVehicleCall(descriptor, args, theme, context);
|
|
509
|
-
},
|
|
510
|
-
renderResult(result, options, theme, context) {
|
|
511
|
-
if (!options.isPartial && !context.isError) {
|
|
512
|
-
const output = (result.details as { output?: unknown } | undefined)?.output;
|
|
513
|
-
// /reload rendering-fallback investigation (papyrus task 4930cd9b) -- correlates
|
|
514
|
-
// against vehicle-notes-client.ts's onInvoked/vehicle-ready diagnostics by
|
|
515
|
-
// descriptor.name and wall-clock time.
|
|
516
|
-
recordRenderDiagnostic({
|
|
517
|
-
event: "render-result-dispatch",
|
|
518
|
-
operation: descriptor.name,
|
|
519
|
-
isArtifact: isArtifact(output),
|
|
520
|
-
isArtifactArray: isArtifactArray(output),
|
|
521
|
-
output: shapeFingerprint(output),
|
|
522
|
-
});
|
|
523
|
-
if (isArtifactArray(output)) {
|
|
524
|
-
return new ArtifactListCard(createArtifactListDetails(descriptor.name, output), theme, options.expanded);
|
|
525
|
-
}
|
|
526
|
-
if (isArtifact(output)) {
|
|
527
|
-
return new ArtifactCard(createArtifactDetails(descriptor.name, output), theme, options.expanded);
|
|
528
|
-
}
|
|
529
|
-
if (isTaskFocus(output)) {
|
|
530
|
-
return new ArtifactCard(
|
|
531
|
-
createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output)),
|
|
532
|
-
theme,
|
|
533
|
-
options.expanded,
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
|
-
// tasks.focused specifically returns null for "nothing focused" --
|
|
537
|
-
// scoped to this one operation so an unrelated null-output operation
|
|
538
|
-
// (e.g. a not-found lookup) is never mislabeled as a focus state.
|
|
539
|
-
if (output === null && descriptor.name === "tasks.focused") {
|
|
540
|
-
return renderNoFocusedTask(theme);
|
|
541
|
-
}
|
|
542
|
-
if (isTaskExecutionPlan(output)) {
|
|
543
|
-
return renderTaskExecutionPlan(output, theme, options.expanded);
|
|
544
|
-
}
|
|
545
|
-
if (isPlaybookInvocationResult(output)) {
|
|
546
|
-
return renderPlaybookInvocationResult(output, theme, options.expanded);
|
|
547
|
-
}
|
|
548
|
-
if (isPlaybookMissingArguments(output)) {
|
|
549
|
-
return renderPlaybookMissingArguments(output, theme);
|
|
550
|
-
}
|
|
551
|
-
if (isDiscussionAndRounds(output)) {
|
|
552
|
-
return renderDiscussionAndRounds(output, theme, options.expanded);
|
|
553
|
-
}
|
|
554
|
-
if (isDiscussionRoundsOnly(output)) {
|
|
555
|
-
return renderDiscussionRoundsOnly(output, theme);
|
|
556
|
-
}
|
|
557
|
-
if (isDiscussionListOutput(output)) {
|
|
558
|
-
return new ArtifactListCard(createArtifactListDetails(descriptor.name, output.discussions), theme, options.expanded);
|
|
559
|
-
}
|
|
560
|
-
if (isTaskCompletion(output)) {
|
|
561
|
-
return renderTaskCompletion(output, theme, options.expanded);
|
|
562
|
-
}
|
|
563
|
-
recordRenderDiagnostic({ event: "render-result-fell-through-to-generic", operation: descriptor.name });
|
|
564
|
-
}
|
|
565
|
-
return renderVehicleResult(descriptor, result, options, theme, context);
|
|
566
|
-
},
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Projects a raw Papyrus operation output into a bounded, versioned PapyrusToolDetails DTO
|
|
572
|
-
* before Pi ever persists it -- the seam papyrusVehicleRenderers' own renderResult never had
|
|
573
|
-
* (it only converts shape at render time, from whatever the legacy {vehicle, output} path
|
|
574
|
-
* already persisted verbatim, lease tokens and all). Every branch here mirrors the same
|
|
575
|
-
* shape-detection papyrusVehicleRenderers's own renderResult uses, so the two stay in lockstep;
|
|
576
|
-
* anything genuinely unmatched still becomes a real, bounded PreviewToolDetails rather than an
|
|
577
|
-
* unprojected raw passthrough -- the one requirement this whole seam exists to satisfy.
|
|
578
|
-
*/
|
|
579
|
-
function projectPapyrusPresentation(descriptor: VehicleOperationDescriptor, output: unknown): JsonValue {
|
|
580
|
-
if (isArtifactArray(output)) return createArtifactListDetails(descriptor.name, output) as unknown as JsonValue;
|
|
581
|
-
if (isArtifact(output)) return createArtifactDetails(descriptor.name, output) as unknown as JsonValue;
|
|
582
|
-
if (isTaskFocus(output)) return createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output)) as unknown as JsonValue;
|
|
583
|
-
if (output === null && descriptor.name === "tasks.focused") return createNoFocusDetails(descriptor.name) as unknown as JsonValue;
|
|
584
|
-
if (isTaskExecutionPlan(output))
|
|
585
|
-
return createExecutionPlanDetails(descriptor.name, output.nodes, output.layers, output.cycleIds) as unknown as JsonValue;
|
|
586
|
-
if (isPlaybookInvocationResult(output)) {
|
|
587
|
-
return createPlaybookInvocationDetails(descriptor.name, {
|
|
588
|
-
playbookId: output.playbookId,
|
|
589
|
-
runId: output.runId,
|
|
590
|
-
created: output.created,
|
|
591
|
-
rootTaskIds: output.rootTaskIds,
|
|
592
|
-
entryTaskId: output.entryTaskId,
|
|
593
|
-
execution: output.execution,
|
|
594
|
-
}) as unknown as JsonValue;
|
|
595
|
-
}
|
|
596
|
-
if (isPlaybookMissingArguments(output)) {
|
|
597
|
-
return createPlaybookMissingArgumentsDetails(descriptor.name, output.playbookId, output.missingArguments) as unknown as JsonValue;
|
|
598
|
-
}
|
|
599
|
-
if (isDiscussionAndRounds(output))
|
|
600
|
-
return createDiscussionDetails(descriptor.name, output.rounds, output.discussion) as unknown as JsonValue;
|
|
601
|
-
if (isDiscussionRoundsOnly(output)) return createDiscussionDetails(descriptor.name, output.rounds) as unknown as JsonValue;
|
|
602
|
-
if (isDiscussionListOutput(output)) return createArtifactListDetails(descriptor.name, output.discussions) as unknown as JsonValue;
|
|
603
|
-
if (isTaskCompletion(output)) return createTaskCompletionDetails(descriptor.name, output) as unknown as JsonValue;
|
|
604
|
-
if (isTaskLeaseView(output)) return createLeaseDetails(descriptor.name, output) as unknown as JsonValue;
|
|
605
|
-
return createPreviewDetails(descriptor.name, descriptor.name, boundedJsonPreview(output)) as unknown as JsonValue;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function renderFromPapyrusPresentation(
|
|
609
|
-
presentation: NonNullable<ReturnType<typeof parsePapyrusToolDetails>>,
|
|
610
|
-
theme: Theme,
|
|
611
|
-
expanded: boolean,
|
|
612
|
-
): Component {
|
|
613
|
-
switch (presentation.kind) {
|
|
614
|
-
case "artifact-list":
|
|
615
|
-
return new ArtifactListCard(presentation, theme, expanded);
|
|
616
|
-
case "artifact":
|
|
617
|
-
return new ArtifactCard(presentation, theme, expanded);
|
|
618
|
-
case "no-focus":
|
|
619
|
-
return renderNoFocusedTask(theme);
|
|
620
|
-
case "execution-plan":
|
|
621
|
-
return renderTaskExecutionPlan(presentation, theme, expanded);
|
|
622
|
-
case "playbook-invocation":
|
|
623
|
-
return renderPlaybookInvocationResult(presentation, theme, expanded);
|
|
624
|
-
case "playbook-missing-arguments":
|
|
625
|
-
return renderPlaybookMissingArguments(presentation, theme);
|
|
626
|
-
case "discussion":
|
|
627
|
-
return presentation.discussion
|
|
628
|
-
? renderDiscussionAndRounds({ discussion: presentation.discussion, rounds: presentation.rounds }, theme, expanded)
|
|
629
|
-
: renderDiscussionRoundsOnly(presentation, theme);
|
|
630
|
-
case "task-completion":
|
|
631
|
-
return renderTaskCompletion(presentation, theme, expanded);
|
|
632
|
-
case "lease":
|
|
633
|
-
return renderLease(presentation, theme);
|
|
634
|
-
case "preview":
|
|
635
|
-
return new Text(theme.fg("toolOutput", presentation.content), 0, 0);
|
|
636
|
-
case "transition":
|
|
637
|
-
case "graph":
|
|
638
|
-
case "gate-run":
|
|
639
|
-
case "invocation":
|
|
640
|
-
case "error":
|
|
641
|
-
// Reachable only if a future caller starts producing these kinds through this seam
|
|
642
|
-
// (today's Papyrus Vehicle outputs never do) -- a bounded JSON preview is still a
|
|
643
|
-
// real, safe rendering rather than a crash.
|
|
644
|
-
return new Text(theme.fg("toolOutput", boundedJsonPreview(presentation)), 0, 0);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Pairs the projector above with a renderResult that reads the already-projected,
|
|
650
|
-
* already-bounded `details.presentation` DTO instead of raw `details.output` -- the seam
|
|
651
|
-
* pi-papyrus task "project typed bounded render details before Vehicle persists" exists for.
|
|
652
|
-
* Falls back to papyrusVehicleRenderers' own renderResult (which still reads `details.output`)
|
|
653
|
-
* for a partial/progress update, an error result, or a historical session row persisted before
|
|
654
|
-
* this seam existed -- both keep working exactly as before, unchanged.
|
|
655
|
-
*/
|
|
656
|
-
export function papyrusVehiclePresentations(descriptor: VehicleOperationDescriptor): PiVehiclePresentationContract {
|
|
657
|
-
return {
|
|
658
|
-
projector: {
|
|
659
|
-
maxBytes: TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS,
|
|
660
|
-
project: (output: unknown, _request: PiVehicleInvocationRequest) => projectPapyrusPresentation(descriptor, output),
|
|
661
|
-
},
|
|
662
|
-
renderResult(result, options, theme, context) {
|
|
663
|
-
if (!options.isPartial && !context.isError) {
|
|
664
|
-
const presentation = parsePapyrusToolDetails((result.details as { presentation?: unknown } | undefined)?.presentation);
|
|
665
|
-
if (presentation) return renderFromPapyrusPresentation(presentation, theme, options.expanded);
|
|
666
|
-
}
|
|
667
|
-
return papyrusVehicleRenderers(descriptor).renderResult!(result, options, theme, context);
|
|
668
|
-
},
|
|
669
|
-
};
|
|
670
|
-
}
|
|
2
|
+
* Barrel re-exporting Papyrus's own Vehicle-projected result renderers from ./renderers/. Kept as
|
|
3
|
+
* a real file (not relying on directory-index resolution) so every existing
|
|
4
|
+
* `from "./vehicle-artifact-renderers.ts"` / `from "../extension/src/tools/vehicle-artifact-renderers.ts"`
|
|
5
|
+
* import (vehicle-notes-client.ts plus 3 test files) keeps resolving unchanged -- this codebase's
|
|
6
|
+
* imports always carry an explicit `.ts` extension, which does not implicitly resolve a bare
|
|
7
|
+
* specifier to a directory's own index file the way Node's CJS `require()` does.
|
|
8
|
+
*
|
|
9
|
+
* The real implementation now lives in ./renderers/ (one file per result-kind, plus index.ts as
|
|
10
|
+
* the Registry assembly point) instead of one 670-line file -- see Doc "Modularity playbook:
|
|
11
|
+
* building-block-shaped TypeScript modules for papyrus/pi-papyrus" and the "pi-papyrus
|
|
12
|
+
* vehicle-artifact-renderers.ts split" child of "Epic: Modularize papyrus/pi-papyrus god-files
|
|
13
|
+
* into building-block modules".
|
|
14
|
+
*/
|
|
15
|
+
export { papyrusVehiclePresentations, papyrusVehicleRenderers } from "./renderers/index.ts";
|