@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 +3 -2
- package/extension/src/artifact-detail-format.ts +25 -10
- package/extension/src/artifact-detail-view.ts +34 -17
- package/extension/src/domain-tools.ts +1 -1
- package/extension/src/markdown.ts +60 -0
- package/extension/src/task-detail-format.ts +29 -10
- package/extension/src/task-detail-view.ts +39 -14
- package/package.json +1 -1
- package/src/cli.ts +16 -6
- package/src/domain/task-event.ts +1 -0
- package/src/service.ts +1 -0
- package/src/task-service.ts +33 -2
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
|
-
|
|
6
|
-
|
|
7
|
-
output += `\n\
|
|
8
|
-
if (
|
|
9
|
-
if (
|
|
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
|
|
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
|
|
24
|
-
private readonly relationships: string[];
|
|
24
|
+
private readonly content: ArtifactDetailContent;
|
|
25
25
|
|
|
26
26
|
constructor(
|
|
27
27
|
private readonly tui: TUI,
|
|
28
|
-
private readonly
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 = [...
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
84
|
+
if (checklist.length > 0) sections.push(checklist);
|
|
79
85
|
const gates = gateLines(task.extra["gates"]);
|
|
80
|
-
if (gates.length > 0)
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
109
|
+
...wrap("Relationships:", "muted"),
|
|
110
|
+
...wrap(" Dependencies point prerequisite → dependent.", "dim"),
|
|
92
111
|
]
|
|
93
112
|
: [];
|
|
94
|
-
this.detailLines = [
|
|
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
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,
|
|
295
|
-
|
|
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;
|
package/src/domain/task-event.ts
CHANGED
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)),
|
package/src/task-service.ts
CHANGED
|
@@ -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
|
}
|