@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.
Files changed (47) hide show
  1. package/README.md +5 -189
  2. package/package.json +8 -16
  3. package/src/artifact-relationship-view.ts +23 -0
  4. package/src/cli.ts +0 -0
  5. package/src/index.ts +32 -0
  6. package/src/task-relationship-view.ts +2 -1
  7. package/extension/src/active-task-continuation.ts +0 -131
  8. package/extension/src/artifact-browser.ts +0 -229
  9. package/extension/src/artifact-detail-format.ts +0 -31
  10. package/extension/src/artifact-detail-view.ts +0 -112
  11. package/extension/src/artifact-format.ts +0 -84
  12. package/extension/src/artifact-status-presentation.ts +0 -71
  13. package/extension/src/base-prompt-breakdown.ts +0 -55
  14. package/extension/src/beautiful-mermaid-renderer.ts +0 -68
  15. package/extension/src/bounded-poll.ts +0 -20
  16. package/extension/src/context-budget.ts +0 -503
  17. package/extension/src/context-injection-telemetry.ts +0 -88
  18. package/extension/src/context-view.ts +0 -222
  19. package/extension/src/discuss-ask-layout.ts +0 -193
  20. package/extension/src/discuss-ask-view.ts +0 -1301
  21. package/extension/src/discuss.ts +0 -134
  22. package/extension/src/discussion-detail-view.ts +0 -136
  23. package/extension/src/docs.ts +0 -58
  24. package/extension/src/domain-tools.ts +0 -886
  25. package/extension/src/index.ts +0 -776
  26. package/extension/src/markdown.ts +0 -60
  27. package/extension/src/note-widget.ts +0 -8
  28. package/extension/src/notes.ts +0 -102
  29. package/extension/src/playbook-bridge.ts +0 -91
  30. package/extension/src/playbooks.ts +0 -97
  31. package/extension/src/rules.ts +0 -51
  32. package/extension/src/service-client.ts +0 -29
  33. package/extension/src/session-identity.ts +0 -22
  34. package/extension/src/skill-catalog-footprint.ts +0 -183
  35. package/extension/src/skills.ts +0 -127
  36. package/extension/src/task-context.ts +0 -1
  37. package/extension/src/task-detail-format.ts +0 -110
  38. package/extension/src/task-detail-view.ts +0 -139
  39. package/extension/src/task-focus-events.ts +0 -57
  40. package/extension/src/task-graph.ts +0 -116
  41. package/extension/src/task-presentation.ts +0 -26
  42. package/extension/src/task-widget.ts +0 -70
  43. package/extension/src/tasks.ts +0 -418
  44. package/extension/src/tool-rendering/artifact-card.ts +0 -117
  45. package/extension/src/tool-rendering/artifact-list.ts +0 -179
  46. package/extension/src/tool-rendering/index.ts +0 -109
  47. package/extension/src/tool-rendering/render-model.ts +0 -410
@@ -1,134 +0,0 @@
1
- /**
2
- * discuss.ts — /discuss interactive panel.
3
- * Reuses the generic artifact browser (artifact-browser.ts), same as docs.ts/rules.ts/notes.ts:
4
- * a Discussion is a `doc` artifact, so the browser's list/filter/refresh/select-action loop
5
- * applies unchanged. The one real wrinkle is that Discuss's meaningful lifecycle state
6
- * (active/deferred/settled) lives in extra.discussion, not the shared doc status column the
7
- * browser colors its row glyph by (see artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION
8
- * comment) -- so the real state is surfaced in rowMeta text instead, the same way rules.ts
9
- * surfaces severity and notes.ts surfaces history count, both also not the row glyph.
10
- *
11
- * Creating a new Discussion is left to the agent (the discuss tool), matching docs.ts/rules.ts/
12
- * skills.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
13
- * because Notes exists specifically as a human-authored inbox.
14
- */
15
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
16
- import type { Artifact } from "../../src/domain/artifact.ts";
17
- import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
18
- import { readDiscussionExtra } from "../../src/domain/discussion.ts";
19
- import { askQuestion } from "./discuss-ask-view.ts";
20
- import { showArtifactBrowser } from "./artifact-browser.ts";
21
- import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
22
- import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
23
- import { callService } from "./service-client.ts";
24
-
25
- const SOURCE = "discuss-tui";
26
- const ACTOR = "human";
27
-
28
- /** Tasks a human could plausibly want to block on -- excludes terminal ones, since blocking already-finished or canceled work is meaningless. */
29
- export async function openTaskChoices(cwd: string): Promise<Artifact[]> {
30
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: cwd });
31
- return rows.filter((task) => task.status !== "done" && task.status !== "canceled");
32
- }
33
-
34
- /** Resolves the task ids a Discussion currently has a `blocks` edge to, by title -- so "Unblock" only ever offers tasks actually blocked by this one, never the whole task list. */
35
- export async function blockedTaskChoices(discussionId: string): Promise<Artifact[]> {
36
- const tree = await callService<Record<string, unknown>, Artifact>("graph.tree", { id: discussionId, depth: 1 });
37
- const blockedIds = (tree.edges ?? []).filter((edge) => edge.relation === "blocks" && edge.from === discussionId).map((edge) => edge.to);
38
- const tasks = await Promise.all(blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)));
39
- return tasks.filter((task): task is Artifact => task !== null);
40
- }
41
-
42
- /** 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. */
43
- export async function pickTaskByName(ctx: ExtensionCommandContext, title: string, tasks: Artifact[]): Promise<Artifact | undefined> {
44
- if (tasks.length === 0) { ctx.ui.notify("No open tasks to choose from.", "info"); return undefined; }
45
- const label = await ctx.ui.select(title, tasks.map((task) => `${task.title} [${task.status}]`));
46
- if (!label) return undefined;
47
- const index = tasks.map((task) => `${task.title} [${task.status}]`).indexOf(label);
48
- return index === -1 ? undefined : tasks[index];
49
- }
50
-
51
- export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
52
- const state = discussionStateOf(discussion);
53
- const presentation = DISCUSSION_STATE_PRESENTATION[state];
54
- const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
55
- const rounds = discussionRoundCountOf(discussion);
56
- const pending = (() => { try { return readDiscussionExtra(discussion.extra).pendingOptions; } catch { return undefined; } })();
57
- const pendingText = pending && pending.length > 0 ? theme.fg("accent", ` · awaiting: ${pending.join("/")}`) : "";
58
- return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}${pendingText}`;
59
- }
60
-
61
- function discussionActions(discussion: Artifact): string[] {
62
- const state = discussionStateOf(discussion);
63
- if (state === "active") return ["Show transcript", "Reply", "Defer", "Settle", "Block a task", "Unblock a task"];
64
- if (state === "deferred") return ["Show transcript", "Resume", "Settle"];
65
- return ["Show transcript"]; // settled, or an unrecognized/corrupt state -- read-only either way
66
- }
67
-
68
- export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
69
- await showArtifactBrowser(ctx, {
70
- kind: "doc",
71
- title: "Discussions",
72
- listOperation: "discuss.list",
73
- statusOrder: ["draft", "active", "archived"],
74
- presentation: DOC_STATUS_PRESENTATION,
75
- rowMeta: discussionRowMeta,
76
- actions: discussionActions,
77
- handleAction: async (choice, discussion, commandCtx) => {
78
- if (choice === "Show transcript") {
79
- const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
80
- await showDiscussionDetailView(commandCtx, result.discussion, result.rounds);
81
- return;
82
- }
83
- if (choice === "Reply") {
84
- const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
85
- // Same fix as the live discuss tool: the most recent round's own content IS the real
86
- // question -- the title becomes a plain orientation subtitle, not a labeled-backwards
87
- // "Context:" section under a generic "Reply to <title>:" wrapper.
88
- const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
89
- const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
90
- const subtitle = discussion.title;
91
- const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
92
- ? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })), allowMultiple: pending.pendingOptionsMode === "multi" })
93
- : await askQuestion(commandCtx, { question, subtitle });
94
- if (!answer) return; // canceled
95
- await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
96
- commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
97
- return;
98
- }
99
- if (choice === "Defer") {
100
- const reason = await commandCtx.ui.input("Defer reason (optional):", "");
101
- await callService("discuss.defer", { id: discussion.id, ...(reason ? { reason } : {}), actor: ACTOR, source: SOURCE });
102
- commandCtx.ui.notify("Deferred.", "info");
103
- return;
104
- }
105
- if (choice === "Resume") {
106
- await callService("discuss.resume", { id: discussion.id, actor: ACTOR, source: SOURCE });
107
- commandCtx.ui.notify("Resumed.", "info");
108
- return;
109
- }
110
- if (choice === "Settle") {
111
- const settlement = await commandCtx.ui.input("Settlement:", "");
112
- if (!settlement) return;
113
- await callService("discuss.settle", { id: discussion.id, settlement, actor: ACTOR, source: SOURCE });
114
- commandCtx.ui.notify("Settled.", "info");
115
- return;
116
- }
117
- if (choice === "Block a task") {
118
- const target = await pickTaskByName(commandCtx, "Block which task?", await openTaskChoices(commandCtx.cwd));
119
- if (!target) return;
120
- await callService("discuss.block", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
121
- commandCtx.ui.notify(`"${discussion.title}" now blocks "${target.title}"`, "info");
122
- return;
123
- }
124
- if (choice === "Unblock a task") {
125
- const blocked = await blockedTaskChoices(discussion.id);
126
- if (blocked.length === 0) { commandCtx.ui.notify("This discussion isn't blocking any task.", "info"); return; }
127
- const target = await pickTaskByName(commandCtx, "Unblock which task?", blocked);
128
- if (!target) return;
129
- const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
130
- commandCtx.ui.notify(result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.", "info");
131
- }
132
- },
133
- });
134
- }
@@ -1,136 +0,0 @@
1
- /**
2
- * discussion-detail-view.ts — the transcript view for a single Discussion.
3
- *
4
- * The generic artifact detail view (artifact-detail-view.ts) formats an artifact's own
5
- * fields (title, body, extra as JSON, edges); it has no way to show a Discussion's rounds,
6
- * since those live in a dedicated child table fetched separately (discuss.show / discuss.rounds),
7
- * not in the artifact row itself. Tasks needed the same kind of dedicated view for the same
8
- * underlying reason (task-detail-view.ts) -- this mirrors that scrolling-viewport idiom rather
9
- * than inventing a new one.
10
- */
11
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
13
- import {
14
- ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
15
- ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
16
- ARTIFACT_DETAIL_RESERVED_ROWS,
17
- } from "../../src/constants.ts";
18
- import type { Artifact } from "../../src/domain/artifact.ts";
19
- import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
20
- import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
21
- import { DISCUSSION_STATE_PRESENTATION } from "./artifact-status-presentation.ts";
22
-
23
- interface TranscriptLine {
24
- text: string;
25
- }
26
-
27
- /** Reads state defensively for display -- a corrupt/foreign extra.discussion shape shows as "unknown" rather than crashing the whole panel over one bad row. */
28
- export function discussionStateOf(discussion: Artifact): string {
29
- try {
30
- return readDiscussionExtra(discussion.extra).state;
31
- } catch {
32
- return "unknown";
33
- }
34
- }
35
-
36
- export function discussionRoundCountOf(discussion: Artifact): number {
37
- try {
38
- return readDiscussionExtra(discussion.extra).roundCount;
39
- } catch {
40
- return 0;
41
- }
42
- }
43
-
44
- class DiscussionTranscriptViewport {
45
- private offsetY = 0;
46
- private renderedWidth = 0;
47
- private lines: TranscriptLine[] = [];
48
- private readonly visibleLines: number;
49
-
50
- constructor(
51
- private readonly tui: TUI,
52
- private readonly activeTheme: ActiveTheme,
53
- private readonly discussion: Artifact,
54
- private readonly rounds: DiscussionRound[],
55
- private readonly close: () => void,
56
- ) {
57
- this.visibleLines = Math.max(
58
- ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
59
- Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
60
- );
61
- }
62
-
63
- invalidate(): void { this.renderedWidth = 0; }
64
-
65
- render(width: number): string[] {
66
- const contentWidth = Math.max(1, width - 2);
67
- this.buildLines(contentWidth);
68
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
69
- const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
70
- const theme = this.activeTheme();
71
- const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
72
- const footer = [
73
- this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
74
- "Esc back",
75
- ].filter(Boolean).join(" · ");
76
- return [
77
- border,
78
- truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
79
- border,
80
- ...this.lines.slice(this.offsetY, end).map((line) => truncateToWidth(` ${line.text}`, width, "")),
81
- truncateToWidth(theme.fg("dim", footer), width, ""),
82
- border,
83
- ];
84
- }
85
-
86
- handleInput(data: string): void {
87
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
88
- if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
89
- else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
90
- else if (matchesKey(data, "pageDown")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
91
- else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
92
- else return;
93
- this.tui.requestRender();
94
- }
95
-
96
- private buildLines(width: number): void {
97
- if (this.renderedWidth === width) return;
98
- this.renderedWidth = width;
99
- const theme = this.activeTheme();
100
- const extra = (() => { try { return readDiscussionExtra(this.discussion.extra); } catch { return undefined; } })();
101
- const presentation = extra ? DISCUSSION_STATE_PRESENTATION[extra.state] : undefined;
102
- const stateLine = presentation
103
- ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
104
- : theme.fg("muted", "state unknown");
105
- const header: TranscriptLine[] = [
106
- { text: theme.bold(this.discussion.title) },
107
- { text: `${stateLine}${theme.fg("dim", ` · ${this.discussion.id}`)}` },
108
- ...(extra?.deferredReason ? [{ text: theme.fg("muted", `Deferred: ${extra.deferredReason}`) }] : []),
109
- ...(extra?.settlement ? [{ text: theme.fg("success", `Settled: ${extra.settlement}`) }] : []),
110
- { text: "" },
111
- ];
112
- const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
113
- const roundHeader = theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
114
- const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
115
- const posed = round.options && round.options.length > 0
116
- ? [{ text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}` }]
117
- : [];
118
- const picked = round.selected && round.selected.length > 0
119
- ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }]
120
- : [];
121
- return [{ text: roundHeader }, ...body, ...posed, ...picked, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
122
- });
123
- this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
124
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
125
- }
126
- }
127
-
128
- export async function showDiscussionDetailView(ctx: ExtensionCommandContext, discussion: Artifact, rounds: DiscussionRound[]): Promise<void> {
129
- if (ctx.mode !== "tui") {
130
- const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
131
- ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
132
- return;
133
- }
134
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
135
- new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done));
136
- }
@@ -1,58 +0,0 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import type { Artifact } from "../../src/domain/artifact.ts";
3
- import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
- import { DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
5
- import { callService } from "./service-client.ts";
6
-
7
- const DOC_ACTIONS: Record<string, string[]> = {
8
- draft: ["Activate", "Archive"],
9
- active: ["Archive"],
10
- archived: ["Reopen"],
11
- };
12
- const DOC_RELATIONS = ["references", "documents", "supersedes", "relates_to", "contains", "part_of"];
13
-
14
- export function documentRowMeta(document: Artifact, theme: Theme): string {
15
- const subtype = document.subtype ? theme.fg("accent", document.subtype) : "";
16
- return [subtype, document.labels.join(", ")].filter(Boolean).join(" · ");
17
- }
18
-
19
- export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
20
- await showArtifactBrowser(ctx, {
21
- kind: "doc",
22
- title: "Documents",
23
- listOperation: "docs.list",
24
- statusOrder: ["draft", "active", "archived"],
25
- presentation: DOC_STATUS_PRESENTATION,
26
- rowMeta: documentRowMeta,
27
- actions: (document) => ["Show details", "Edit", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
28
- handleAction: async (choice, document, commandCtx) => {
29
- if (choice === "Show details") {
30
- await showArtifactDetails(commandCtx, document.id, "docs.show");
31
- return;
32
- }
33
- if (choice === "Edit") {
34
- const title = await commandCtx.ui.input("Title:", document.title);
35
- if (title === undefined) return; // canceled
36
- const body = await commandCtx.ui.input("Body:", document.body);
37
- if (body === undefined) return; // canceled
38
- const updated = await callService<Record<string, unknown>, Artifact>("docs.update", { id: document.id, title, body });
39
- commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
40
- return;
41
- }
42
- if (choice === "Link artifact") {
43
- const targetId = await commandCtx.ui.input("Target artifact id:", "");
44
- if (!targetId) return;
45
- const relation = await commandCtx.ui.select("Relation", DOC_RELATIONS);
46
- if (!relation) return;
47
- await callService("docs.link", { id: document.id, relation, target_id: targetId });
48
- commandCtx.ui.notify(`Linked "${document.title}" via ${relation}`, "info");
49
- return;
50
- }
51
- const operation = choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
52
- if (operation) {
53
- const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: document.id });
54
- commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
55
- }
56
- },
57
- });
58
- }