@danypops/pi-papyrus 0.43.1 → 0.43.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/extension/src/artifact-browser.ts +54 -31
  2. package/extension/src/artifact-detail-view.ts +52 -42
  3. package/extension/src/artifact-format.ts +11 -5
  4. package/extension/src/artifact-relationship-lines.ts +4 -5
  5. package/extension/src/beautiful-mermaid-renderer.ts +3 -5
  6. package/extension/src/context-budget.ts +8 -5
  7. package/extension/src/context-hub-contribution.ts +6 -2
  8. package/extension/src/context-injection-telemetry.ts +2 -2
  9. package/extension/src/discuss-ask-layout.ts +3 -1
  10. package/extension/src/discuss-ask-view.ts +437 -111
  11. package/extension/src/discuss.ts +64 -15
  12. package/extension/src/discussion-detail-view.ts +44 -22
  13. package/extension/src/docs.ts +3 -2
  14. package/extension/src/domain-tools.ts +79 -34
  15. package/extension/src/index.ts +170 -66
  16. package/extension/src/markdown.ts +3 -7
  17. package/extension/src/note-widget.ts +1 -1
  18. package/extension/src/notes.ts +3 -8
  19. package/extension/src/playbook-bridge.ts +17 -6
  20. package/extension/src/playbooks.ts +17 -7
  21. package/extension/src/rules.ts +5 -5
  22. package/extension/src/service-client.ts +23 -8
  23. package/extension/src/skill-catalog-footprint.ts +1 -1
  24. package/extension/src/task-detail-format.ts +9 -9
  25. package/extension/src/task-detail-view.ts +36 -29
  26. package/extension/src/task-focus-events.ts +3 -2
  27. package/extension/src/task-graph.ts +16 -12
  28. package/extension/src/task-presentation.ts +2 -6
  29. package/extension/src/task-widget.ts +12 -8
  30. package/extension/src/tasks.ts +148 -57
  31. package/extension/src/tool-rendering/artifact-card.ts +16 -4
  32. package/extension/src/tool-rendering/artifact-list.ts +23 -24
  33. package/extension/src/tool-rendering/index.ts +2 -6
  34. package/extension/src/tool-rendering/render-model.ts +96 -56
  35. package/extension/src/vehicle-artifact-renderers.ts +102 -0
  36. package/extension/src/vehicle-notes-client.ts +35 -7
  37. package/package.json +5 -5
@@ -12,11 +12,12 @@
12
12
  * playbooks.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
13
13
  * because Notes exists specifically as a human-authored inbox.
14
14
  */
15
+
16
+ import { type Artifact, type DiscussionAndRounds, readDiscussionExtra } from "@danypops/papyrus";
15
17
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
16
- import { readDiscussionExtra, type Artifact, type DiscussionAndRounds } from "@danypops/papyrus";
17
- import { askQuestion } from "./discuss-ask-view.ts";
18
18
  import { showArtifactBrowser } from "./artifact-browser.ts";
19
19
  import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
20
+ import { askQuestion } from "./discuss-ask-view.ts";
20
21
  import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
21
22
  import { callService } from "./service-client.ts";
22
23
 
@@ -33,14 +34,22 @@ export async function openTaskChoices(cwd: string): Promise<Artifact[]> {
33
34
  export async function blockedTaskChoices(discussionId: string): Promise<Artifact[]> {
34
35
  const tree = await callService<Record<string, unknown>, Artifact>("graph.tree", { id: discussionId, depth: 1 });
35
36
  const blockedIds = (tree.edges ?? []).filter((edge) => edge.relation === "blocks" && edge.from === discussionId).map((edge) => edge.to);
36
- const tasks = await Promise.all(blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)));
37
+ const tasks = await Promise.all(
38
+ blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)),
39
+ );
37
40
  return tasks.filter((task): task is Artifact => task !== null);
38
41
  }
39
42
 
40
43
  /** Picking a task by title, the same ui.select pattern used for Discuss's own single-choice options -- no ecosystem extension (Pi's own docs/examples, pi-tasks) builds a bespoke fuzzy picker for a plain "choose one named thing" list. */
41
44
  export async function pickTaskByName(ctx: ExtensionCommandContext, title: string, tasks: Artifact[]): Promise<Artifact | undefined> {
42
- if (tasks.length === 0) { ctx.ui.notify("No open tasks to choose from.", "info"); return undefined; }
43
- const label = await ctx.ui.select(title, tasks.map((task) => `${task.title} [${task.status}]`));
45
+ if (tasks.length === 0) {
46
+ ctx.ui.notify("No open tasks to choose from.", "info");
47
+ return undefined;
48
+ }
49
+ const label = await ctx.ui.select(
50
+ title,
51
+ tasks.map((task) => `${task.title} [${task.status}]`),
52
+ );
44
53
  if (!label) return undefined;
45
54
  const index = tasks.map((task) => `${task.title} [${task.status}]`).indexOf(label);
46
55
  return index === -1 ? undefined : tasks[index];
@@ -49,9 +58,17 @@ export async function pickTaskByName(ctx: ExtensionCommandContext, title: string
49
58
  export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
50
59
  const state = discussionStateOf(discussion);
51
60
  const presentation = DISCUSSION_STATE_PRESENTATION[state];
52
- const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
61
+ const stateText = presentation
62
+ ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
63
+ : theme.fg("muted", "state unknown");
53
64
  const rounds = discussionRoundCountOf(discussion);
54
- const pending = (() => { try { return readDiscussionExtra(discussion.extra).pendingOptions; } catch { return undefined; } })();
65
+ const pending = (() => {
66
+ try {
67
+ return readDiscussionExtra(discussion.extra).pendingOptions;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ })();
55
72
  const pendingText = pending && pending.length > 0 ? theme.fg("accent", ` · awaiting: ${pending.join("/")}`) : "";
56
73
  return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}${pendingText}`;
57
74
  }
@@ -79,18 +96,39 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
79
96
  return;
80
97
  }
81
98
  if (choice === "Reply") {
82
- const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
99
+ const pending = (() => {
100
+ try {
101
+ return readDiscussionExtra(discussion.extra);
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ })();
83
106
  // Same fix as the live discuss tool: the most recent round's own content IS the real
84
107
  // question -- the title becomes a plain orientation subtitle, not a labeled-backwards
85
108
  // "Context:" section under a generic "Reply to <title>:" wrapper.
86
109
  const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
87
110
  const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
88
111
  const subtitle = discussion.title;
89
- const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
90
- ? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })), allowMultiple: pending.pendingOptionsMode === "multi" })
91
- : await askQuestion(commandCtx, { question, subtitle });
112
+ const answer =
113
+ pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
114
+ ? await askQuestion(commandCtx, {
115
+ question,
116
+ subtitle,
117
+ options: pending.pendingOptions.map((title, index) => ({
118
+ title,
119
+ description: pending.pendingOptionDescriptions?.[index] || undefined,
120
+ })),
121
+ allowMultiple: pending.pendingOptionsMode === "multi",
122
+ })
123
+ : await askQuestion(commandCtx, { question, subtitle });
92
124
  if (!answer) return; // canceled
93
- await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
125
+ await callService("discuss.reply", {
126
+ id: discussion.id,
127
+ actor: ACTOR,
128
+ content: answer.content,
129
+ ...(answer.selected ? { selected: answer.selected } : {}),
130
+ source: SOURCE,
131
+ });
94
132
  commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
95
133
  return;
96
134
  }
@@ -121,11 +159,22 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
121
159
  }
122
160
  if (choice === "Unblock a task") {
123
161
  const blocked = await blockedTaskChoices(discussion.id);
124
- if (blocked.length === 0) { commandCtx.ui.notify("This discussion isn't blocking any task.", "info"); return; }
162
+ if (blocked.length === 0) {
163
+ commandCtx.ui.notify("This discussion isn't blocking any task.", "info");
164
+ return;
165
+ }
125
166
  const target = await pickTaskByName(commandCtx, "Unblock which task?", blocked);
126
167
  if (!target) return;
127
- const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
128
- commandCtx.ui.notify(result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.", "info");
168
+ const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", {
169
+ id: discussion.id,
170
+ task_id: target.id,
171
+ actor: ACTOR,
172
+ source: SOURCE,
173
+ });
174
+ commandCtx.ui.notify(
175
+ result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.",
176
+ "info",
177
+ );
129
178
  }
130
179
  },
131
180
  });
@@ -8,18 +8,19 @@
8
8
  * underlying reason (task-detail-view.ts) -- this mirrors that scrolling-viewport idiom rather
9
9
  * than inventing a new one.
10
10
  */
11
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
11
+
13
12
  import {
14
13
  ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
15
14
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
16
15
  ARTIFACT_DETAIL_RESERVED_ROWS,
17
- readDiscussionExtra,
18
16
  type Artifact,
19
17
  type DiscussionRound,
18
+ readDiscussionExtra,
20
19
  } from "@danypops/papyrus";
21
- import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
20
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
21
+ import { matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
22
22
  import { DISCUSSION_STATE_PRESENTATION } from "./artifact-status-presentation.ts";
23
+ import { type ActiveTheme, renderMarkdownBody } from "./markdown.ts";
23
24
 
24
25
  interface TranscriptLine {
25
26
  text: string;
@@ -61,7 +62,9 @@ class DiscussionTranscriptViewport {
61
62
  );
62
63
  }
63
64
 
64
- invalidate(): void { this.renderedWidth = 0; }
65
+ invalidate(): void {
66
+ this.renderedWidth = 0;
67
+ }
65
68
 
66
69
  render(width: number): string[] {
67
70
  const contentWidth = Math.max(1, width - 2);
@@ -70,10 +73,9 @@ class DiscussionTranscriptViewport {
70
73
  const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
71
74
  const theme = this.activeTheme();
72
75
  const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
73
- const footer = [
74
- this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
75
- "Esc back",
76
- ].filter(Boolean).join(" · ");
76
+ const footer = [this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "", "Esc back"]
77
+ .filter(Boolean)
78
+ .join(" · ");
77
79
  return [
78
80
  border,
79
81
  truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
@@ -85,10 +87,14 @@ class DiscussionTranscriptViewport {
85
87
  }
86
88
 
87
89
  handleInput(data: string): void {
88
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
90
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
91
+ this.close();
92
+ return;
93
+ }
89
94
  if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
90
95
  else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
91
- else if (matchesKey(data, "pageDown")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
96
+ else if (matchesKey(data, "pageDown"))
97
+ this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
92
98
  else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
93
99
  else return;
94
100
  this.tui.requestRender();
@@ -98,7 +104,13 @@ class DiscussionTranscriptViewport {
98
104
  if (this.renderedWidth === width) return;
99
105
  this.renderedWidth = width;
100
106
  const theme = this.activeTheme();
101
- const extra = (() => { try { return readDiscussionExtra(this.discussion.extra); } catch { return undefined; } })();
107
+ const extra = (() => {
108
+ try {
109
+ return readDiscussionExtra(this.discussion.extra);
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ })();
102
114
  const presentation = extra ? DISCUSSION_STATE_PRESENTATION[extra.state] : undefined;
103
115
  const stateLine = presentation
104
116
  ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
@@ -111,14 +123,19 @@ class DiscussionTranscriptViewport {
111
123
  { text: "" },
112
124
  ];
113
125
  const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
114
- const roundHeader = theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
126
+ const roundHeader =
127
+ theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
115
128
  const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
116
- const posed = round.options && round.options.length > 0
117
- ? [{ text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}` }]
118
- : [];
119
- const picked = round.selected && round.selected.length > 0
120
- ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }]
121
- : [];
129
+ const posed =
130
+ round.options && round.options.length > 0
131
+ ? [
132
+ {
133
+ text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}`,
134
+ },
135
+ ]
136
+ : [];
137
+ const picked =
138
+ round.selected && round.selected.length > 0 ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }] : [];
122
139
  return [{ text: roundHeader }, ...body, ...posed, ...picked, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
123
140
  });
124
141
  this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
@@ -126,12 +143,17 @@ class DiscussionTranscriptViewport {
126
143
  }
127
144
  }
128
145
 
129
- export async function showDiscussionDetailView(ctx: ExtensionCommandContext, discussion: Artifact, rounds: DiscussionRound[]): Promise<void> {
146
+ export async function showDiscussionDetailView(
147
+ ctx: ExtensionCommandContext,
148
+ discussion: Artifact,
149
+ rounds: DiscussionRound[],
150
+ ): Promise<void> {
130
151
  if (ctx.mode !== "tui") {
131
152
  const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
132
153
  ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
133
154
  return;
134
155
  }
135
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
136
- new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done));
156
+ await ctx.ui.custom<void>(
157
+ (tui, theme, _keybindings, done) => new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done),
158
+ );
137
159
  }
@@ -1,5 +1,5 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
1
  import type { Artifact } from "@danypops/papyrus";
2
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
4
  import { DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
5
5
  import { callService } from "./service-client.ts";
@@ -48,7 +48,8 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
48
48
  commandCtx.ui.notify(`Linked "${document.title}" via ${relation}`, "info");
49
49
  return;
50
50
  }
51
- const operation = choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
51
+ const operation =
52
+ choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
52
53
  if (operation) {
53
54
  const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: document.id });
54
55
  commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
@@ -1,12 +1,6 @@
1
+ import { type Artifact, type DiscussionAndRounds, type DiscussionRound, type OperationName, readDiscussionExtra } from "@danypops/papyrus";
1
2
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
- import {
4
- readDiscussionExtra,
5
- type Artifact,
6
- type DiscussionAndRounds,
7
- type DiscussionRound,
8
- type OperationName,
9
- } from "@danypops/papyrus";
10
4
  import { askQuestion } from "./discuss-ask-view.ts";
11
5
  import { callService } from "./service-client.ts";
12
6
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
@@ -43,7 +37,11 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
43
37
  const titles: string[] = [];
44
38
  const descriptions: string[] = [];
45
39
  for (const entry of raw) {
46
- if (typeof entry === "string") { titles.push(entry); descriptions.push(""); continue; }
40
+ if (typeof entry === "string") {
41
+ titles.push(entry);
42
+ descriptions.push("");
43
+ continue;
44
+ }
47
45
  if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
48
46
  const record = entry as Record<string, unknown>;
49
47
  titles.push(record.title as string);
@@ -59,9 +57,21 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
59
57
  if (anyDescription) params.option_descriptions = descriptions;
60
58
  }
61
59
 
62
- async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
60
+ async function liveAnswer(
61
+ ctx: ExtensionContext,
62
+ discussion: Artifact,
63
+ latestContent: string | undefined,
64
+ onUpdate: AgentToolUpdateCallback | undefined,
65
+ signal: AbortSignal | undefined,
66
+ ): Promise<{ content: string; selected?: string[] } | undefined> {
63
67
  if (!ctx.hasUI) return undefined;
64
- const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
68
+ const pending = (() => {
69
+ try {
70
+ return readDiscussionExtra(discussion.extra);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ })();
65
75
  // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
66
76
  // wrapper as the primary question, with the real content demoted to "Context:", left a human
67
77
  // staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
@@ -72,7 +82,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
72
82
  return askQuestion(ctx, {
73
83
  question,
74
84
  subtitle,
75
- options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })),
85
+ options: pending.pendingOptions.map((title, index) => ({
86
+ title,
87
+ description: pending.pendingOptionDescriptions?.[index] || undefined,
88
+ })),
76
89
  allowMultiple: pending.pendingOptionsMode === "multi",
77
90
  onUpdate,
78
91
  signal,
@@ -96,7 +109,9 @@ export function artifactLine(artifact: Artifact): string {
96
109
  export function artifactLines(artifacts: Artifact[]): string[] {
97
110
  const titleCounts = new Map<string, number>();
98
111
  for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
99
- return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
112
+ return artifacts.map((artifact) =>
113
+ titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact),
114
+ );
100
115
  }
101
116
 
102
117
  /**
@@ -110,7 +125,9 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
110
125
  const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
111
126
  if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
112
127
  if (matches.length > 1) {
113
- throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((artifact) => `${artifact.title} (${artifact.id})`).join(", ")} -- use id to disambiguate`);
128
+ throw new Error(
129
+ `${matches.length} artifacts are named "${name}": ${matches.map((artifact) => `${artifact.title} (${artifact.id})`).join(", ")} -- use id to disambiguate`,
130
+ );
114
131
  }
115
132
  return matches[0]!.id;
116
133
  }
@@ -126,9 +143,7 @@ const SCOPE_AWARE_LIST_OPERATIONS = new Set<OperationName>(["tasks.list"]);
126
143
 
127
144
  /** The widened-scope request tried once when a name isn't found under the caller's current scope. */
128
145
  function widenedRequest(listOperation: OperationName, baseRequest: Record<string, unknown>): Record<string, unknown> {
129
- return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation)
130
- ? { ...baseRequest, scope: "all" }
131
- : { ...baseRequest, project_root: undefined };
146
+ return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation) ? { ...baseRequest, scope: "all" } : { ...baseRequest, project_root: undefined };
132
147
  }
133
148
 
134
149
  /**
@@ -147,13 +162,21 @@ function widenedRequest(listOperation: OperationName, baseRequest: Record<string
147
162
  * widening, so the caller can surface that a search went wider than the caller's default scope
148
163
  * rather than resolving silently.
149
164
  */
150
- async function resolveArtifactIdByName(listOperation: OperationName, baseRequest: Record<string, unknown>, name: string, notes?: string[]): Promise<string> {
165
+ async function resolveArtifactIdByName(
166
+ listOperation: OperationName,
167
+ baseRequest: Record<string, unknown>,
168
+ name: string,
169
+ notes?: string[],
170
+ ): Promise<string> {
151
171
  const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
152
172
  try {
153
173
  return matchArtifactByName(candidates, name);
154
174
  } catch (error) {
155
- if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest["scope"] !== undefined) throw error;
156
- const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...widenedRequest(listOperation, baseRequest), text: name });
175
+ if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest.scope !== undefined) throw error;
176
+ const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, {
177
+ ...widenedRequest(listOperation, baseRequest),
178
+ text: name,
179
+ });
157
180
  const id = matchArtifactByName(widenedCandidates, name);
158
181
  notes?.push(`"${name}" was not found in the current project scope; resolved across all projects instead.`);
159
182
  return id;
@@ -201,13 +224,13 @@ async function resolveNameArrayField(
201
224
  * over the same two operations rather than reinventing trash semantics four times.
202
225
  * Returns null when action is neither, so callers fall through to their own dispatch.
203
226
  */
204
- async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
227
+ async function _handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
205
228
  // Trashed/restored artifacts stay directly showable, so known identities render by title on
206
229
  // either side of the action. An unresolved explicit id stays in structured/error channels;
207
230
  // normal model text does not turn that backend key into the artifact's public name.
208
231
  const titleOf = async (): Promise<string> => {
209
232
  try {
210
- const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params["id"] });
233
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params.id });
211
234
  return artifact ? `"${artifact.title}"` : "unknown artifact";
212
235
  } catch {
213
236
  return "unknown artifact";
@@ -215,7 +238,10 @@ async function handleArtifactRemoveRestore(action: unknown, params: Record<strin
215
238
  };
216
239
  if (action === "remove") {
217
240
  const label = await titleOf();
218
- const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
241
+ const record = await callService<
242
+ Record<string, unknown>,
243
+ { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }
244
+ >("artifact.remove", params);
219
245
  const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
220
246
  return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
221
247
  }
@@ -242,7 +268,8 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
242
268
  pi.registerTool({
243
269
  name: "discuss",
244
270
  label: "Discuss",
245
- 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.",
271
+ description:
272
+ "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.",
246
273
  parameters: Type.Object({
247
274
  action: Type.String(),
248
275
  id: Type.Optional(Type.String()),
@@ -261,7 +288,9 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
261
288
  state: Type.Optional(Type.String()),
262
289
  after_round: Type.Optional(Type.Number()),
263
290
  limit: Type.Optional(Type.Number()),
264
- options: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })]))),
291
+ options: Type.Optional(
292
+ Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })])),
293
+ ),
265
294
  options_mode: Type.Optional(Type.String()),
266
295
  selected: Type.Optional(Type.Array(Type.String())),
267
296
  live: Type.Optional(Type.Boolean()),
@@ -270,8 +299,12 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
270
299
  // back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
271
300
  // bash/edit/write and let those run before the human sees the prompt.
272
301
  executionMode: "sequential",
273
- renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
274
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
302
+ renderCall(args, theme) {
303
+ return renderPapyrusToolCall("Discuss", args, theme);
304
+ },
305
+ renderResult(result, options, theme, context) {
306
+ return renderPapyrusToolResult(result, options, theme, context);
307
+ },
275
308
  async execute(_id, rawParams, signal, onUpdate, ctx) {
276
309
  try {
277
310
  const params: Record<string, unknown> = { ...rawParams };
@@ -290,14 +323,22 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
290
323
  if (typeof params.actor !== "string" || params.actor.length === 0) params.actor = "agent";
291
324
  const operation = action === "open" ? "discuss.open" : "discuss.reply";
292
325
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
293
- const fallback = action === "open"
294
- ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
295
- : text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
326
+ const fallback =
327
+ action === "open"
328
+ ? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
329
+ : text(
330
+ `Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`,
331
+ createArtifactDetails("discuss.reply", result.discussion),
332
+ );
296
333
  if (params.live !== true) return fallback;
297
334
  const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
298
335
  if (!answer) return fallback;
299
336
  const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
300
- id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
337
+ id: result.discussion.id,
338
+ actor: "human",
339
+ content: answer.content,
340
+ ...(answer.selected ? { selected: answer.selected } : {}),
341
+ source: "discuss-live",
301
342
  });
302
343
  return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
303
344
  }
@@ -309,9 +350,10 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
309
350
  callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
310
351
  ]);
311
352
  const discussion = discussionAndRounds.discussion;
312
- const message = action === "unblock" && !outcome.unblocked
313
- ? "No such blocking relationship."
314
- : `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
353
+ const message =
354
+ action === "unblock" && !outcome.unblocked
355
+ ? "No such blocking relationship."
356
+ : `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
315
357
  return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
316
358
  }
317
359
  if (action === "show") {
@@ -326,7 +368,10 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
326
368
  }
327
369
  if (action === "list") {
328
370
  const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
329
- return text(rows.length ? artifactLines(rows).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
371
+ return text(
372
+ rows.length ? artifactLines(rows).join("\n") : "No discussions found.",
373
+ createArtifactListDetails("discuss.list", rows),
374
+ );
330
375
  }
331
376
  const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
332
377
  const operation = operations[action as keyof typeof operations];