@danypops/papyrus 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,7 +116,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
116
116
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
117
117
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
118
118
 
119
- All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable text.
119
+ All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
120
120
 
121
121
  ## Notes
122
122
 
@@ -168,6 +168,7 @@ papyrus tasks pause --json
168
168
  papyrus tasks unpause --json
169
169
  papyrus tasks clear-focus --json
170
170
  papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
171
+ papyrus tasks update <id> --status todo --reason "created with legacy default" --json
171
172
  papyrus tasks start <id> --json
172
173
  papyrus tasks submit <id> --json
173
174
  papyrus tasks complete <id> --json
@@ -201,7 +202,7 @@ Papyrus also injects an Alef-style reconciliation block at `before_agent_start`
201
202
 
202
203
  After assembling each system-prompt addition, Papyrus emits a versioned `papyrus.context-injection.v1` observation on Pi's shared extension event bus. It contains only exact byte/character sizes, Rule count, a labeled token estimate, prompt share, sequence, and a SHA-256 payload fingerprint; Rule/Task text, prompts, project paths, and credentials are never included. Jittor can persist and assess these observations without Papyrus maintaining a second telemetry store.
203
204
 
204
- Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact.
205
+ Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact. The same `update` action provides a narrowly guarded recovery for Tasks accidentally created terminal by a legacy default: `status=todo` requires an audit reason, cannot be combined with content edits, only applies when `created` is the sole lifecycle event, and appends `creation_recovered` rather than rewriting history.
205
206
 
206
207
  ## Why
207
208
 
@@ -1,16 +1,31 @@
1
1
  import type { Artifact } from "../../src/domain/artifact.ts";
2
2
  import { formatMetadata } from "./artifact-format.ts";
3
3
 
4
+ export interface ArtifactDetailContent {
5
+ title: string;
6
+ identity: string;
7
+ body: string;
8
+ labels: string[];
9
+ metadata: string[];
10
+ relationships: string[];
11
+ }
12
+
13
+ export function artifactDetailContent(artifact: Artifact): ArtifactDetailContent {
14
+ return {
15
+ title: artifact.title,
16
+ identity: `${artifact.id} [${artifact.kind}|${artifact.status}]${artifact.subtype ? ` · ${artifact.subtype}` : ""}`,
17
+ body: artifact.body || "(no body)",
18
+ labels: [...artifact.labels],
19
+ metadata: Object.keys(artifact.extra).length > 0 ? formatMetadata(artifact.extra) : [],
20
+ relationships: (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`),
21
+ };
22
+ }
23
+
4
24
  export function artifactDetailsText(artifact: Artifact): string {
5
- let output = `${artifact.title}\n${artifact.id} [${artifact.kind}|${artifact.status}]`;
6
- if (artifact.subtype) output += ` · ${artifact.subtype}`;
7
- output += `\n\n${artifact.body || "(no body)"}`;
8
- if (artifact.labels.length > 0) output += `\n\nLabels: ${artifact.labels.join(", ")}`;
9
- if (Object.keys(artifact.extra).length > 0) {
10
- output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
11
- }
12
- if (artifact.edges?.length) {
13
- output += `\n\nRelationships:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
14
- }
25
+ const content = artifactDetailContent(artifact);
26
+ let output = `${content.title}\n${content.identity}\n\n${content.body}`;
27
+ if (content.labels.length > 0) output += `\n\nLabels: ${content.labels.join(", ")}`;
28
+ if (content.metadata.length > 0) output += `\n\nMetadata:\n${content.metadata.map((line) => ` ${line}`).join("\n")}`;
29
+ if (content.relationships.length > 0) output += `\n\nRelationships:\n${content.relationships.map((line) => ` ${line}`).join("\n")}`;
15
30
  return output;
16
31
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
@@ -7,7 +7,8 @@ import {
7
7
  ARTIFACT_DETAIL_RESERVED_ROWS,
8
8
  } from "../../src/constants.ts";
9
9
  import type { Artifact } from "../../src/domain/artifact.ts";
10
- import { artifactDetailsText } from "./artifact-detail-format.ts";
10
+ import { artifactDetailContent, artifactDetailsText, type ArtifactDetailContent } from "./artifact-detail-format.ts";
11
+ import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
11
12
 
12
13
  interface ArtifactDetailLine {
13
14
  text: string;
@@ -20,12 +21,11 @@ class ArtifactDetailViewport {
20
21
  private renderedWidth = 0;
21
22
  private lines: ArtifactDetailLine[] = [];
22
23
  private readonly visibleLines: number;
23
- private readonly narrative: string;
24
- private readonly relationships: string[];
24
+ private readonly content: ArtifactDetailContent;
25
25
 
26
26
  constructor(
27
27
  private readonly tui: TUI,
28
- private readonly theme: Theme,
28
+ private readonly activeTheme: ActiveTheme,
29
29
  artifact: Artifact,
30
30
  private readonly close: () => void,
31
31
  ) {
@@ -33,8 +33,7 @@ class ArtifactDetailViewport {
33
33
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
34
34
  Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
35
35
  );
36
- this.narrative = artifactDetailsText({ ...artifact, edges: undefined });
37
- this.relationships = (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`);
36
+ this.content = artifactDetailContent(artifact);
38
37
  }
39
38
 
40
39
  invalidate(): void { this.renderedWidth = 0; }
@@ -42,11 +41,12 @@ class ArtifactDetailViewport {
42
41
  render(width: number): string[] {
43
42
  const contentWidth = Math.max(1, width - 2);
44
43
  this.buildLines(contentWidth);
45
- const wideWidth = this.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
44
+ const wideWidth = this.content.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
46
45
  this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
47
46
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
48
47
  const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
49
- const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
48
+ const theme = this.activeTheme();
49
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
50
50
  const footer = [
51
51
  wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
52
52
  this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
@@ -54,12 +54,12 @@ class ArtifactDetailViewport {
54
54
  ].filter(Boolean).join(" · ");
55
55
  return [
56
56
  border,
57
- truncateToWidth(this.theme.bold("Artifact details"), width, ""),
57
+ truncateToWidth(theme.fg("accent", theme.bold("Artifact details")), width, ""),
58
58
  border,
59
59
  ...this.lines.slice(this.offsetY, end).map((line) => line.wide
60
60
  ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
61
61
  : truncateToWidth(` ${line.text}`, width, "")),
62
- truncateToWidth(this.theme.fg("dim", footer), width, ""),
62
+ truncateToWidth(theme.fg("dim", footer), width, ""),
63
63
  border,
64
64
  ];
65
65
  }
@@ -77,12 +77,29 @@ class ArtifactDetailViewport {
77
77
  private buildLines(width: number): void {
78
78
  if (this.renderedWidth === width) return;
79
79
  this.renderedWidth = width;
80
- const narrative = this.narrative.split("\n").flatMap((line) =>
81
- (line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, wide: false })));
82
- const relationshipSection = this.relationships.length > 0
83
- ? [{ text: "", wide: false }, { text: "Relationships:", wide: false }, ...this.relationships.map((text) => ({ text, wide: true }))]
80
+ const theme = this.activeTheme();
81
+ const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): ArtifactDetailLine[] =>
82
+ (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, wide: false }));
83
+ const identity = [
84
+ ...wrap(theme.bold(this.content.title)),
85
+ ...wrap(this.content.identity, "muted"),
86
+ { text: "", wide: false },
87
+ ];
88
+ const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, wide: false }));
89
+ const labels = this.content.labels.length > 0
90
+ ? [{ text: "", wide: false }, ...wrap("Labels:", "muted"), ...wrap(this.content.labels.join(", "))]
91
+ : [];
92
+ const metadata = this.content.metadata.length > 0
93
+ ? [{ text: "", wide: false }, ...wrap("Metadata:", "muted"), ...this.content.metadata.flatMap((line) => wrap(` ${line}`, "dim"))]
94
+ : [];
95
+ const relationships = this.content.relationships.length > 0
96
+ ? [
97
+ { text: "", wide: false },
98
+ ...wrap("Relationships:", "muted"),
99
+ ...this.content.relationships.map((text) => ({ text: theme.fg("text", text), wide: true })),
100
+ ]
84
101
  : [];
85
- this.lines = [...narrative, ...relationshipSection];
102
+ this.lines = [...identity, ...body, ...labels, ...metadata, ...relationships];
86
103
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
87
104
  }
88
105
  }
@@ -91,5 +108,5 @@ export async function showArtifactDetailView(ctx: ExtensionCommandContext, artif
91
108
  const output = artifactDetailsText(artifact);
92
109
  if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
93
110
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
94
- new ArtifactDetailViewport(tui, theme, artifact, done));
111
+ new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, done));
95
112
  }
@@ -32,7 +32,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
32
32
  pi.registerTool({
33
33
  name: "tasks",
34
34
  label: "Tasks",
35
- description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
35
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
36
36
  parameters: Type.Object({
37
37
  action: Type.String(),
38
38
  id: Type.Optional(Type.String()),
@@ -0,0 +1,60 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
3
+
4
+ export type ActiveTheme = () => Theme;
5
+ export type ActiveMarkdownTheme = () => Pick<MarkdownTheme, "highlightCode">;
6
+
7
+ function activePiMarkdownTheme(): Pick<MarkdownTheme, "highlightCode"> {
8
+ try {
9
+ return getMarkdownTheme();
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+
15
+ export function createPapyrusMarkdownTheme(
16
+ activeTheme: ActiveTheme,
17
+ activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
18
+ ): MarkdownTheme {
19
+ return {
20
+ heading: (text) => activeTheme().fg("mdHeading", text),
21
+ link: (text) => activeTheme().fg("mdLink", text),
22
+ linkUrl: (text) => activeTheme().fg("mdLinkUrl", text),
23
+ code: (text) => activeTheme().fg("mdCode", text),
24
+ codeBlock: (text) => activeTheme().fg("mdCodeBlock", text),
25
+ codeBlockBorder: (text) => activeTheme().fg("mdCodeBlockBorder", text),
26
+ quote: (text) => activeTheme().fg("mdQuote", text),
27
+ quoteBorder: (text) => activeTheme().fg("mdQuoteBorder", text),
28
+ hr: (text) => activeTheme().fg("mdHr", text),
29
+ listBullet: (text) => activeTheme().fg("mdListBullet", text),
30
+ bold: (text) => activeTheme().bold(text),
31
+ italic: (text) => activeTheme().italic(text),
32
+ strikethrough: (text) => activeTheme().strikethrough(text),
33
+ underline: (text) => activeTheme().underline(text),
34
+ highlightCode: (code, language) => {
35
+ try {
36
+ const highlighted = activeMarkdownTheme().highlightCode?.(code, language);
37
+ if (highlighted) return highlighted;
38
+ } catch {
39
+ // The host theme may be unavailable in isolated rendering tests.
40
+ }
41
+ return code.split("\n").map((line) => activeTheme().fg("mdCodeBlock", line));
42
+ },
43
+ };
44
+ }
45
+
46
+ export function renderMarkdownBody(
47
+ body: string,
48
+ width: number,
49
+ activeTheme: ActiveTheme,
50
+ activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
51
+ ): string[] {
52
+ const markdown = new Markdown(
53
+ body || "(no body)",
54
+ 0,
55
+ 0,
56
+ createPapyrusMarkdownTheme(activeTheme, activeMarkdownTheme),
57
+ { color: (text) => activeTheme().fg("text", text) },
58
+ );
59
+ return markdown.render(Math.max(1, width));
60
+ }
@@ -70,19 +70,38 @@ function historyLines(history: TaskEvent[]): string[] {
70
70
  return lines;
71
71
  }
72
72
 
73
- export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
74
- let output = `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}\n${task.id} [task|${task.status}]`;
75
- if (task.labels.length > 0) output += `\nLabels: ${task.labels.join(", ")}`;
76
- output += `\n\n${task.body || "(no body)"}`;
73
+ export interface TaskDetailContent {
74
+ headline: string;
75
+ identity: string;
76
+ labels: string[];
77
+ body: string;
78
+ sections: string[][];
79
+ }
80
+
81
+ export function taskDetailContent(task: Artifact, history: TaskEvent[] = []): TaskDetailContent {
82
+ const sections: string[][] = [];
77
83
  const checklist = checklistLines(task.extra["checklist"]);
78
- if (checklist.length > 0) output += `\n\n${checklist.join("\n")}`;
84
+ if (checklist.length > 0) sections.push(checklist);
79
85
  const gates = gateLines(task.extra["gates"]);
80
- if (gates.length > 0) output += `\n\n${gates.join("\n")}`;
86
+ if (gates.length > 0) sections.push(gates);
81
87
  const metadata = Object.fromEntries(Object.entries(task.extra).filter(([key]) => key !== "checklist" && key !== "gates"));
82
- if (Object.keys(metadata).length > 0) {
83
- output += `\n\nMetadata:\n${formatMetadata(metadata).map((line) => ` ${line}`).join("\n")}`;
84
- }
85
- output += `\n\n${historyLines(history).join("\n")}`;
88
+ if (Object.keys(metadata).length > 0) sections.push(["Metadata:", ...formatMetadata(metadata).map((line) => ` ${line}`)]);
89
+ sections.push(historyLines(history));
90
+ return {
91
+ headline: `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}`,
92
+ identity: `${task.id} [task|${task.status}]`,
93
+ labels: [...task.labels],
94
+ body: task.body || "(no body)",
95
+ sections,
96
+ };
97
+ }
98
+
99
+ export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
100
+ const content = taskDetailContent(task, history);
101
+ let output = `${content.headline}\n${content.identity}`;
102
+ if (content.labels.length > 0) output += `\nLabels: ${content.labels.join(", ")}`;
103
+ output += `\n\n${content.body}`;
104
+ for (const section of content.sections) output += `\n\n${section.join("\n")}`;
86
105
  if (task.edges?.length) {
87
106
  const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
88
107
  output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
@@ -1,4 +1,4 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  TASK_DETAIL_HORIZONTAL_PAN_COLUMNS,
@@ -12,7 +12,9 @@ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
12
12
  import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
13
13
  import type { TaskGraph } from "../../src/task-service.ts";
14
14
  import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
15
- import { taskDetailsText } from "./task-detail-format.ts";
15
+ import { taskDetailContent, taskDetailsText, type TaskDetailContent } from "./task-detail-format.ts";
16
+ import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
17
+ import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
16
18
 
17
19
  interface DetailLine {
18
20
  text: string;
@@ -25,11 +27,12 @@ class TaskDetailViewport {
25
27
  private renderedWidth = 0;
26
28
  private detailLines: DetailLine[] = [];
27
29
  private readonly visibleLines: number;
28
- private readonly narrative: string;
30
+ private readonly content: TaskDetailContent;
31
+ private readonly status: Artifact["status"];
29
32
 
30
33
  constructor(
31
34
  private readonly tui: TUI,
32
- private readonly theme: Theme,
35
+ private readonly activeTheme: ActiveTheme,
33
36
  task: Artifact,
34
37
  private readonly graphLines: string[],
35
38
  history: TaskEvent[],
@@ -39,7 +42,8 @@ class TaskDetailViewport {
39
42
  TASK_DETAIL_MIN_VISIBLE_LINES,
40
43
  Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
41
44
  );
42
- this.narrative = taskDetailsText({ ...task, edges: undefined }, [], history);
45
+ this.content = taskDetailContent(task, history);
46
+ this.status = task.status;
43
47
  }
44
48
 
45
49
  invalidate(): void { this.renderedWidth = 0; }
@@ -51,7 +55,8 @@ class TaskDetailViewport {
51
55
  this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
52
56
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
53
57
  const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
54
- const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
58
+ const theme = this.activeTheme();
59
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
55
60
  const footer = [
56
61
  graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
57
62
  this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
@@ -59,12 +64,12 @@ class TaskDetailViewport {
59
64
  ].filter(Boolean).join(" · ");
60
65
  return [
61
66
  border,
62
- truncateToWidth(this.theme.bold("Task details"), width, ""),
67
+ truncateToWidth(theme.fg("accent", theme.bold("Task details")), width, ""),
63
68
  border,
64
69
  ...this.detailLines.slice(this.offsetY, end).map((line) => line.graph
65
70
  ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
66
71
  : truncateToWidth(` ${line.text}`, width, "")),
67
- truncateToWidth(this.theme.fg("dim", footer), width, ""),
72
+ truncateToWidth(theme.fg("dim", footer), width, ""),
68
73
  border,
69
74
  ];
70
75
  }
@@ -82,16 +87,36 @@ class TaskDetailViewport {
82
87
  private buildLines(width: number): void {
83
88
  if (this.renderedWidth === width) return;
84
89
  this.renderedWidth = width;
85
- const narrative = this.narrative.split("\n").flatMap((line) =>
86
- (line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, graph: false })));
90
+ const theme = this.activeTheme();
91
+ const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): DetailLine[] =>
92
+ (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, graph: false }));
93
+ const status = TASK_STATUS_PRESENTATION[this.status as keyof typeof TASK_STATUS_PRESENTATION];
94
+ const headline = status ? theme.fg(status.color, theme.bold(this.content.headline)) : theme.bold(this.content.headline);
95
+ const identity = [
96
+ ...wrapTextWithAnsi(headline, width).map((text) => ({ text, graph: false })),
97
+ ...wrap(this.content.identity, "muted"),
98
+ ...(this.content.labels.length > 0 ? wrap(`Labels: ${this.content.labels.join(", ")}`, "muted") : []),
99
+ { text: "", graph: false },
100
+ ];
101
+ const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
102
+ const sections = this.content.sections.flatMap((section) => [
103
+ { text: "", graph: false },
104
+ ...section.flatMap((line, index) => wrap(line, index === 0 ? "muted" : "dim")),
105
+ ]);
87
106
  const relationshipHeader = this.graphLines.length > 0
88
107
  ? [
89
108
  { text: "", graph: false },
90
- { text: "Relationships:", graph: false },
91
- { text: " Dependencies point prerequisite → dependent.", graph: false },
109
+ ...wrap("Relationships:", "muted"),
110
+ ...wrap(" Dependencies point prerequisite → dependent.", "dim"),
92
111
  ]
93
112
  : [];
94
- this.detailLines = [...narrative, ...relationshipHeader, ...this.graphLines.map((text) => ({ text, graph: true }))];
113
+ this.detailLines = [
114
+ ...identity,
115
+ ...body,
116
+ ...sections,
117
+ ...relationshipHeader,
118
+ ...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
119
+ ];
95
120
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
96
121
  }
97
122
  }
@@ -110,5 +135,5 @@ export async function showTaskDetails(
110
135
  return;
111
136
  }
112
137
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
113
- new TaskDetailViewport(tui, theme, task, relationshipGraph, history, done));
138
+ new TaskDetailViewport(tui, () => ctx.ui.theme ?? theme, task, relationshipGraph, history, done));
114
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/cli.ts CHANGED
@@ -75,7 +75,7 @@ const USAGE = `Usage:
75
75
  papyrus tasks scope [project|all|graph <root-id>] [--json]
76
76
  papyrus tasks assign-project <id> [project-root] [--json]
77
77
  papyrus tasks focus <id> [--json]
78
- papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
78
+ papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
79
79
  papyrus tasks complete <id> [--json]
80
80
  papyrus tasks start <id> [--json]
81
81
  papyrus tasks submit <id> [--json]
@@ -237,16 +237,21 @@ export async function runNoteCli(args: string[], client: TaskCliClient, projectR
237
237
  export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
238
238
  const json = args.includes("--json");
239
239
  const positional: string[] = [];
240
- const updateInput: { title?: string; body?: string; labels?: string[] } = {};
240
+ const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
241
+ let reason: string | undefined;
241
242
  for (let index = 0; index < args.length; index++) {
242
243
  const argument = args[index]!;
243
244
  if (argument === "--json") continue;
244
- if (argument === "--title" || argument === "--body" || argument === "--labels-json") {
245
+ if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
245
246
  const value = args[++index];
246
247
  if (value === undefined) throw new Error(`${argument} requires a value`);
247
248
  if (argument === "--title") updateInput.title = value;
248
249
  else if (argument === "--body") updateInput.body = value;
249
- else {
250
+ else if (argument === "--reason") reason = value;
251
+ else if (argument === "--status") {
252
+ if (value !== "todo") throw new Error("--status only supports todo for accidental creation recovery");
253
+ updateInput.status = value;
254
+ } else {
250
255
  const parsed = JSON.parse(value) as unknown;
251
256
  if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("--labels-json requires a JSON string array");
252
257
  updateInput.labels = parsed as string[];
@@ -256,6 +261,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
256
261
  positional.push(argument);
257
262
  }
258
263
  const [action, id, dependencyId] = positional;
264
+ if (reason !== undefined && action !== "update") throw new Error("--reason is only supported by tasks update");
259
265
  let result: unknown;
260
266
  let human: string;
261
267
  switch (action) {
@@ -291,8 +297,12 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
291
297
  }
292
298
  case "update": {
293
299
  if (!id || dependencyId) throw new Error("tasks update requires exactly one task id");
294
- if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, or --labels-json");
295
- const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.update", { id, ...updateInput, actor: "user", source: "cli" });
300
+ if (Object.keys(updateInput).length === 0) throw new Error("tasks update requires --title, --body, --labels-json, or --status todo");
301
+ if (updateInput.status !== undefined && !reason?.trim()) throw new Error("tasks update --status requires --reason");
302
+ if (reason !== undefined && updateInput.status === undefined) throw new Error("tasks update --reason requires --status todo");
303
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.update", {
304
+ id, ...updateInput, ...(reason ? { reason } : {}), actor: "user", source: "cli",
305
+ });
296
306
  result = artifact;
297
307
  human = `Updated: ${artifactLabel(artifact)}`;
298
308
  break;
@@ -9,6 +9,7 @@ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected"
9
9
 
10
10
  export const TASK_EVENT_TYPES = [
11
11
  "created",
12
+ "creation_recovered",
12
13
  "updated",
13
14
  "started",
14
15
  "submitted",
package/src/service.ts CHANGED
@@ -269,6 +269,7 @@ function handlers(
269
269
  ...(input["title"] !== undefined ? { title: optionalString(input, "title")! } : {}),
270
270
  ...(input["body"] !== undefined ? { body: optionalString(input, "body")! } : {}),
271
271
  ...(input["labels"] !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}),
272
+ ...(input["status"] !== undefined ? { status: string(input, "status") as "todo" } : {}),
272
273
  }, eventContext(input)),
273
274
  "tasks.list": (input) => tasks.list(taskFilter(input)),
274
275
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
@@ -24,6 +24,7 @@ export interface UpdateTaskInput {
24
24
  title?: string;
25
25
  body?: string;
26
26
  labels?: string[];
27
+ status?: "todo";
27
28
  }
28
29
 
29
30
  export interface TaskFilter {
@@ -148,7 +149,7 @@ export class Tasks {
148
149
  title: input.title,
149
150
  body: input.body,
150
151
  subtype: input.subtype,
151
- status: input.status,
152
+ status: input.status ?? "todo",
152
153
  labels: input.labels,
153
154
  extra,
154
155
  templateId: input.templateId,
@@ -161,9 +162,39 @@ export class Tasks {
161
162
  });
162
163
  }
163
164
 
165
+ private recoverCreation(id: string, context: TaskEventContext): Artifact {
166
+ if (!context.reason?.trim()) throw new Error("creation recovery requires an audit reason");
167
+ return this.events.atomic(() => {
168
+ const task = this.require(id);
169
+ if (task.status !== "done" && task.status !== "canceled") throw new Error(`cannot recover task creation from ${task.status}`);
170
+ const history = this.events.history(id, { direction: "asc", limit: 2 });
171
+ const created = history.events[0];
172
+ if (history.events.length !== 1 || history.nextCursor !== undefined || created?.type !== "created" || created.toStatus !== task.status) {
173
+ throw new Error("task was not terminal at creation");
174
+ }
175
+ const recovered = this.artifacts.setStatus(id, "todo");
176
+ if (!recovered) throw new Error(`task "${id}" not found`);
177
+ this.appendEvent({
178
+ taskId: id,
179
+ type: "creation_recovered",
180
+ fromStatus: task.status as TaskStatus,
181
+ toStatus: "todo",
182
+ evidence: { result: "terminal-at-creation" },
183
+ }, context);
184
+ return recovered;
185
+ });
186
+ }
187
+
164
188
  update(id: string, input: UpdateTaskInput, context: TaskEventContext = {}): Artifact {
189
+ if (input.status !== undefined) {
190
+ if (input.status !== "todo") throw new Error("task status updates only support recovering creation to todo");
191
+ if (input.title !== undefined || input.body !== undefined || input.labels !== undefined) {
192
+ throw new Error("task creation recovery cannot be combined with content updates");
193
+ }
194
+ return this.recoverCreation(id, context);
195
+ }
165
196
  const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
166
- if (fields.length === 0) throw new Error("task update requires title, body, or labels");
197
+ if (fields.length === 0) throw new Error("task update requires title, body, or labels; status todo is only valid for creation recovery");
167
198
  if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
168
199
  throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
169
200
  }