@danypops/papyrus 0.2.1 → 0.3.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.
- package/README.md +35 -11
- package/extension/src/active-task-continuation.ts +12 -18
- package/extension/src/artifact-format.ts +5 -3
- package/extension/src/domain-tools.ts +18 -4
- package/extension/src/index.ts +16 -22
- package/extension/src/task-detail-format.ts +5 -3
- package/extension/src/task-graph.ts +11 -1
- package/extension/src/task-presentation.ts +26 -0
- package/extension/src/task-widget.ts +27 -20
- package/extension/src/tasks.ts +68 -31
- package/package.json +1 -1
- package/src/adapters/sqlite-task-focus-store.ts +31 -0
- package/src/cli.ts +54 -5
- package/src/client.ts +2 -2
- package/src/constants.ts +11 -10
- package/src/db.ts +73 -20
- package/src/ports/task-focus-store.ts +21 -0
- package/src/service.ts +44 -11
- package/src/task-context.ts +10 -9
- package/src/task-execution.ts +16 -3
- package/src/task-graph-view.ts +6 -3
- package/src/task-service.ts +110 -27
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
81
81
|
|
|
82
82
|
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
83
83
|
|
|
84
|
-
- **`tasks`** — create/list/show/plan, replace evidence-bearing checklists, hierarchy/dependencies,
|
|
84
|
+
- **`tasks`** — create/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
85
85
|
- **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
|
|
86
86
|
- **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
87
87
|
- **`skills`** — create/list/show/invoke, enable/disable, create templates, and instantiate templates
|
|
@@ -105,12 +105,28 @@ Run `/tasks` for the interactive task panel:
|
|
|
105
105
|
|
|
106
106
|
- `/` filters; arrow keys navigate; Enter opens task actions
|
|
107
107
|
- `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
|
|
108
|
-
- advance the `
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
-
|
|
108
|
+
- advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
|
|
109
|
+
- use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
|
|
110
|
+
- starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
|
|
111
|
+
- passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
|
|
112
|
+
- successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
113
|
+
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
114
|
+
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
115
|
+
- Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
116
|
+
- the compact persistent widget shows bounded open work in containment order and always retains the active focus
|
|
117
|
+
|
|
118
|
+
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
papyrus tasks active --json
|
|
122
|
+
papyrus tasks focus <id> --json
|
|
123
|
+
papyrus tasks start <id> --json
|
|
124
|
+
papyrus tasks submit <id> --json
|
|
125
|
+
papyrus tasks complete <id> --json
|
|
126
|
+
papyrus tasks reject <id> --json
|
|
127
|
+
papyrus tasks retry <id> --json
|
|
128
|
+
papyrus tasks cancel <id> --json
|
|
129
|
+
```
|
|
114
130
|
|
|
115
131
|
Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
|
|
116
132
|
|
|
@@ -127,9 +143,9 @@ checklist: {
|
|
|
127
143
|
|
|
128
144
|
Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
|
|
129
145
|
|
|
130
|
-
Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run
|
|
146
|
+
Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
|
|
131
147
|
|
|
132
|
-
In TUI and RPC modes, the extension checks
|
|
148
|
+
In TUI and RPC modes, the extension checks the singleton active focus at Pi’s public `agent_settled` lifecycle boundary. If a focused task remains and no continuation is already pending, it queues one hidden next turn. No manual driving command is required. Driving is single-flight and pauses after 20 automatic turns or 6 unchanged task snapshots; human input and task progress reset the bounded counters automatically.
|
|
133
149
|
|
|
134
150
|
## Why
|
|
135
151
|
|
|
@@ -140,12 +156,20 @@ Papyrus keeps SQLite’s local simplicity while centralizing writes, migrations,
|
|
|
140
156
|
Install the published Pi package, then install its supervised user service:
|
|
141
157
|
|
|
142
158
|
```bash
|
|
143
|
-
|
|
159
|
+
packed install npm:@danypops/papyrus
|
|
144
160
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
145
161
|
```
|
|
146
162
|
|
|
163
|
+
Existing databases are never migrated on daemon boot. After upgrading across the task-lifecycle schema boundary, run the authenticated CLI migration explicitly:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
|
|
170
|
+
|
|
147
171
|
Reload Pi once the service is active. Git installs remain available for development builds:
|
|
148
172
|
|
|
149
173
|
```bash
|
|
150
|
-
|
|
174
|
+
packed install git:github.com/DanyPops/papyrus
|
|
151
175
|
```
|
|
@@ -22,23 +22,17 @@ export interface ActiveTaskContinuationDecision {
|
|
|
22
22
|
prompt?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const DISPLAYED_TASK_LIMIT = 3;
|
|
26
25
|
const TITLE_LIMIT = 120;
|
|
27
26
|
|
|
28
|
-
function fingerprint(
|
|
29
|
-
return
|
|
30
|
-
.sort((left, right) => left.id.localeCompare(right.id))
|
|
31
|
-
.map((task) => `${task.id}:${task.updated_at}`)
|
|
32
|
-
.join("|");
|
|
27
|
+
function fingerprint(task: ActiveTaskMarker): string {
|
|
28
|
+
return `${task.id}:${task.updated_at}`;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
function continuationPrompt(
|
|
36
|
-
const names = tasks.slice(0, DISPLAYED_TASK_LIMIT).map((task) => `- ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`);
|
|
31
|
+
function continuationPrompt(task: ActiveTaskMarker): string {
|
|
37
32
|
return [
|
|
38
|
-
"Continue active Papyrus
|
|
39
|
-
"Reconcile
|
|
40
|
-
|
|
41
|
-
...names,
|
|
33
|
+
"Continue the active Papyrus Task now; do not hand off merely because the previous Pi run settled.",
|
|
34
|
+
"Reconcile its lifecycle, take the next concrete action, use tools, submit it for review when implementation effort is ready, and run gates plus checklist review before completion.",
|
|
35
|
+
`Active task: ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`,
|
|
42
36
|
].join("\n");
|
|
43
37
|
}
|
|
44
38
|
|
|
@@ -56,16 +50,16 @@ export class ActiveTaskContinuation {
|
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
evaluate(
|
|
53
|
+
evaluate(task: ActiveTaskMarker | null, context: { idle: boolean; pendingMessages: boolean }): ActiveTaskContinuationDecision {
|
|
60
54
|
if (!context.idle) return { action: "wait", reason: "Pi is not settled" };
|
|
61
55
|
if (context.pendingMessages) return { action: "wait", reason: "Pi already has pending messages" };
|
|
62
56
|
if (this.queued) return { action: "wait", reason: "continuation already queued" };
|
|
63
|
-
if (
|
|
57
|
+
if (!task) {
|
|
64
58
|
this.resetProgress();
|
|
65
|
-
return { action: "wait", reason: "no active
|
|
59
|
+
return { action: "wait", reason: "no active task" };
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
const currentFingerprint = fingerprint(
|
|
62
|
+
const currentFingerprint = fingerprint(task);
|
|
69
63
|
if (currentFingerprint !== this.lastFingerprint) {
|
|
70
64
|
this.lastFingerprint = currentFingerprint;
|
|
71
65
|
this.unchangedTurns = 0;
|
|
@@ -85,8 +79,8 @@ export class ActiveTaskContinuation {
|
|
|
85
79
|
this.consecutiveTurns += 1;
|
|
86
80
|
return {
|
|
87
81
|
action: "continue",
|
|
88
|
-
reason: "active
|
|
89
|
-
prompt: continuationPrompt(
|
|
82
|
+
reason: "an active task remains",
|
|
83
|
+
prompt: continuationPrompt(task),
|
|
90
84
|
};
|
|
91
85
|
}
|
|
92
86
|
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
} from "../../src/constants.ts";
|
|
7
7
|
|
|
8
8
|
const STATUS_GLYPHS: Record<string, string> = {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
todo: "○",
|
|
10
|
+
"in-progress": "●",
|
|
11
|
+
review: "◆",
|
|
12
|
+
rejected: "▲",
|
|
11
13
|
done: "■",
|
|
12
|
-
|
|
14
|
+
canceled: "×",
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export interface MetadataFormatOptions {
|
|
@@ -29,7 +29,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
29
29
|
pi.registerTool({
|
|
30
30
|
name: "tasks",
|
|
31
31
|
label: "Tasks",
|
|
32
|
-
description: "Task domain tool. ACTIONS: create, list, show, plan,
|
|
32
|
+
description: "Task domain tool. ACTIONS: create, list, show, plan, active, 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.",
|
|
33
33
|
parameters: Type.Object({
|
|
34
34
|
action: Type.String(),
|
|
35
35
|
id: Type.Optional(Type.String()),
|
|
@@ -63,6 +63,10 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
63
63
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
64
64
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
65
65
|
}
|
|
66
|
+
if (action === "active") {
|
|
67
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
|
|
68
|
+
return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
|
|
69
|
+
}
|
|
66
70
|
if (action === "plan") {
|
|
67
71
|
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
|
|
68
72
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
@@ -83,17 +87,27 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
83
87
|
if (action === "complete") {
|
|
84
88
|
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
|
|
85
89
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
86
|
-
const
|
|
90
|
+
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
91
|
+
const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
|
|
87
92
|
const blocked = result.blocked.length > 0
|
|
88
93
|
? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
89
94
|
: "";
|
|
90
|
-
return text(`${result.completed ? "Completed" : "
|
|
95
|
+
return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
|
|
91
96
|
}
|
|
92
97
|
if (action === "run_gates") {
|
|
93
98
|
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
|
|
94
99
|
return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
|
|
95
100
|
}
|
|
96
|
-
const operations = {
|
|
101
|
+
const operations = {
|
|
102
|
+
focus: "tasks.focus",
|
|
103
|
+
start: "tasks.start",
|
|
104
|
+
submit: "tasks.submit",
|
|
105
|
+
reject: "tasks.reject",
|
|
106
|
+
retry: "tasks.retry",
|
|
107
|
+
cancel: "tasks.cancel",
|
|
108
|
+
depend: "tasks.depend",
|
|
109
|
+
contain: "tasks.contain",
|
|
110
|
+
} as const;
|
|
97
111
|
const operation = operations[action as keyof typeof operations];
|
|
98
112
|
if (!operation) return text(`Unknown tasks action: ${action}`);
|
|
99
113
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
package/extension/src/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "
|
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
13
|
import {
|
|
14
|
-
TASK_DRIVER_ACTIVE_LIMIT,
|
|
15
14
|
TASK_DRIVER_MAX_TURNS,
|
|
16
15
|
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
16
|
} from "../../src/constants.ts";
|
|
@@ -20,9 +19,10 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
20
19
|
import { formatMetadata } from "./artifact-format.ts";
|
|
21
20
|
import { callService } from "./service-client.ts";
|
|
22
21
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
23
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
22
|
+
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
24
23
|
import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
25
24
|
import { buildTaskWidgetProjection } from "./task-widget.ts";
|
|
25
|
+
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
26
26
|
|
|
27
27
|
function text(t: string, details: Record<string, unknown> = {}) {
|
|
28
28
|
return { content: [{ type: "text" as const, text: t }], details };
|
|
@@ -32,13 +32,6 @@ function text(t: string, details: Record<string, unknown> = {}) {
|
|
|
32
32
|
// Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
const GLYPHS: Record<string, (theme: Theme) => string> = {
|
|
36
|
-
pending: (t) => t.fg("dim", "○"),
|
|
37
|
-
active: (t) => t.fg("warning", "●"),
|
|
38
|
-
done: (t) => t.fg("success", "■"),
|
|
39
|
-
failed: (t) => t.fg("error", "▲"),
|
|
40
|
-
};
|
|
41
|
-
|
|
42
35
|
const WIDGET_KEY = "pi-papyrus";
|
|
43
36
|
|
|
44
37
|
class TaskOverlay {
|
|
@@ -103,22 +96,26 @@ class TaskOverlay {
|
|
|
103
96
|
const projection = buildTaskWidgetProjection(this.snapshot);
|
|
104
97
|
if (projection.total === 0) return [];
|
|
105
98
|
|
|
106
|
-
if (projection.
|
|
107
|
-
return [truncateToWidth(theme.bold("Tasks · no
|
|
99
|
+
if (projection.openTotal === 0) {
|
|
100
|
+
return [truncateToWidth(theme.bold("Tasks · no open tasks · /tasks"), width, "…")];
|
|
108
101
|
}
|
|
109
102
|
|
|
103
|
+
const active = projection.rows.find((row) => row.active);
|
|
110
104
|
const lines = [
|
|
111
105
|
truncateToWidth(
|
|
112
|
-
theme.bold(`Tasks · ${
|
|
106
|
+
theme.bold(`Tasks · ${active ? theme.fg("accent", "▶ active") : "no active focus"} · ${projection.openTotal} open`),
|
|
113
107
|
width,
|
|
114
108
|
"…",
|
|
115
109
|
),
|
|
116
110
|
];
|
|
117
|
-
for (
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
for (let index = 0; index < projection.rows.length; index++) {
|
|
112
|
+
const row = projection.rows[index]!;
|
|
113
|
+
const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
|
|
114
|
+
const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
|
|
115
|
+
const focus = row.active ? theme.fg("accent", "▶") : " ";
|
|
116
|
+
const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
|
|
117
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
|
|
118
|
+
lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
|
|
122
119
|
}
|
|
123
120
|
return lines;
|
|
124
121
|
}
|
|
@@ -145,10 +142,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
145
142
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
146
143
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
147
144
|
try {
|
|
148
|
-
const active = await callService<Record<string,
|
|
149
|
-
status: "active",
|
|
150
|
-
limit: TASK_DRIVER_ACTIVE_LIMIT,
|
|
151
|
-
});
|
|
145
|
+
const active = await callService<Record<string, never>, ActiveTaskMarker | null>("tasks.active", {});
|
|
152
146
|
const decision = taskContinuation.evaluate(active, {
|
|
153
147
|
idle: ctx.isIdle(),
|
|
154
148
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -372,7 +366,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
372
366
|
pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
|
|
373
367
|
|
|
374
368
|
// ── "Are we there yet?" — inject active tasks into every turn ──────
|
|
375
|
-
// The agent sees its open work items every turn. If there are
|
|
369
|
+
// The agent sees its open work items every turn. If there are rejected
|
|
376
370
|
// tasks, they're explicitly called out — the agent should address them.
|
|
377
371
|
|
|
378
372
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
@@ -3,10 +3,12 @@ import { checklistEntries, type ProofReference } from "../../src/domain/checklis
|
|
|
3
3
|
import { formatMetadata } from "./artifact-format.ts";
|
|
4
4
|
|
|
5
5
|
const TASK_STATUS_GLYPHS: Record<string, string> = {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
todo: "○",
|
|
7
|
+
"in-progress": "●",
|
|
8
|
+
review: "◆",
|
|
9
|
+
rejected: "▲",
|
|
8
10
|
done: "■",
|
|
9
|
-
|
|
11
|
+
canceled: "×",
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
function proofLine(proof: ProofReference): string {
|
|
@@ -10,9 +10,18 @@ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
|
10
10
|
import { projectTaskGraph, type TaskGraphView } from "../../src/task-graph-view.ts";
|
|
11
11
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
12
12
|
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
13
|
+
import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
|
|
13
14
|
|
|
14
15
|
const GRAPH_VIEWS: TaskGraphView[] = ["execution", "dependencies", "composition"];
|
|
15
16
|
|
|
17
|
+
export function colorizeTaskGraphLine(theme: Theme, line: string): string {
|
|
18
|
+
let colored = line;
|
|
19
|
+
for (const presentation of Object.values(TASK_STATUS_PRESENTATION)) {
|
|
20
|
+
colored = colored.replaceAll(presentation.glyph, theme.fg(presentation.color, presentation.glyph));
|
|
21
|
+
}
|
|
22
|
+
return colored.replaceAll("▶", theme.fg("accent", "▶"));
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export class TaskGraphViewport {
|
|
17
26
|
private viewIndex = 0;
|
|
18
27
|
private offsetX = 0;
|
|
@@ -51,7 +60,8 @@ export class TaskGraphViewport {
|
|
|
51
60
|
truncateToWidth(this.theme.bold(`Task graph · ${GRAPH_VIEWS[this.viewIndex]}`), contentWidth, ""),
|
|
52
61
|
truncateToWidth(this.theme.fg("dim", `Tab switch · arrows pan · Esc back${position}`), contentWidth, ""),
|
|
53
62
|
border,
|
|
54
|
-
...this.graphLines.slice(this.offsetY, end).map((line) =>
|
|
63
|
+
...this.graphLines.slice(this.offsetY, end).map((line) =>
|
|
64
|
+
colorizeTaskGraphLine(this.theme, sliceByColumn(line, this.offsetX, contentWidth, true))),
|
|
55
65
|
border,
|
|
56
66
|
];
|
|
57
67
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskStatus } from "../../src/task-service.ts";
|
|
3
|
+
|
|
4
|
+
export interface TaskStatusPresentation {
|
|
5
|
+
label: string;
|
|
6
|
+
glyph: string;
|
|
7
|
+
color: ThemeColor;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TASK_STATUS_PRESENTATION: Record<TaskStatus, TaskStatusPresentation> = {
|
|
11
|
+
todo: { label: "To-Do", glyph: "○", color: "muted" },
|
|
12
|
+
"in-progress": { label: "in-progress", glyph: "●", color: "warning" },
|
|
13
|
+
review: { label: "review", glyph: "◆", color: "mdLink" },
|
|
14
|
+
rejected: { label: "rejected", glyph: "▲", color: "accent" },
|
|
15
|
+
done: { label: "done", glyph: "■", color: "success" },
|
|
16
|
+
canceled: { label: "canceled", glyph: "×", color: "error" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function taskTreeConnector(options: {
|
|
20
|
+
depth: number;
|
|
21
|
+
hasChildren: boolean;
|
|
22
|
+
hasLaterSibling: boolean;
|
|
23
|
+
}): string {
|
|
24
|
+
if (options.depth === 0) return options.hasChildren ? "▾" : "·";
|
|
25
|
+
return `${"│ ".repeat(Math.max(0, options.depth - 1))}${options.hasLaterSibling ? "├─" : "└─"}`;
|
|
26
|
+
}
|
|
@@ -1,49 +1,56 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TASK_WIDGET_OPEN_LIMIT } from "../../src/constants.ts";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
4
4
|
|
|
5
5
|
export interface TaskWidgetRow {
|
|
6
6
|
task: Artifact;
|
|
7
7
|
depth: number;
|
|
8
|
-
|
|
8
|
+
hasOpenChildren: boolean;
|
|
9
|
+
active: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface TaskWidgetProjection {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
rows: TaskWidgetRow[];
|
|
14
|
+
openTotal: number;
|
|
14
15
|
total: number;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
function isOpen(task: Artifact): boolean {
|
|
19
|
+
return task.status !== "done" && task.status !== "canceled";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Keep bounded actionable work in containment order while always retaining active focus. */
|
|
18
23
|
export function buildTaskWidgetProjection(
|
|
19
24
|
graph: TaskGraph,
|
|
20
|
-
|
|
25
|
+
openLimit = TASK_WIDGET_OPEN_LIMIT,
|
|
21
26
|
): TaskWidgetProjection {
|
|
22
|
-
const
|
|
23
|
-
const byId = new Map(visibleNodes.map((node) => [node.task.id, node]));
|
|
27
|
+
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
24
28
|
const visited = new Set<string>();
|
|
25
29
|
const ordered: TaskWidgetRow[] = [];
|
|
26
30
|
|
|
27
|
-
const visit = (id: string,
|
|
31
|
+
const visit = (id: string, openDepth: number): void => {
|
|
28
32
|
if (visited.has(id)) return;
|
|
29
33
|
const node = byId.get(id);
|
|
30
34
|
if (!node) return;
|
|
31
35
|
visited.add(id);
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
const childDepth =
|
|
36
|
+
const open = isOpen(node.task);
|
|
37
|
+
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true });
|
|
38
|
+
const childDepth = open ? openDepth + 1 : openDepth;
|
|
35
39
|
for (const childId of node.childIds) visit(childId, childDepth);
|
|
36
40
|
};
|
|
37
41
|
|
|
38
42
|
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
39
|
-
for (const node of
|
|
43
|
+
for (const node of graph.nodes) visit(node.task.id, 0);
|
|
40
44
|
for (let index = 0; index < ordered.length - 1; index++) {
|
|
41
|
-
ordered[index]!.
|
|
45
|
+
ordered[index]!.hasOpenChildren = ordered[index + 1]!.depth > ordered[index]!.depth;
|
|
42
46
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
|
|
48
|
+
const limit = Math.max(0, openLimit);
|
|
49
|
+
let rows = ordered.slice(0, limit);
|
|
50
|
+
const active = ordered.find((row) => row.active);
|
|
51
|
+
if (active && !rows.some((row) => row.task.id === active.task.id) && limit > 0) {
|
|
52
|
+
rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
|
|
53
|
+
.sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
|
|
54
|
+
}
|
|
55
|
+
return { rows, openTotal: ordered.length, total: graph.nodes.length };
|
|
49
56
|
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -15,23 +15,16 @@ export { showTaskDetails } from "./task-detail-view.ts";
|
|
|
15
15
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
16
16
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
17
17
|
import { projectTaskExecution } from "../../src/task-execution.ts";
|
|
18
|
-
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
19
|
-
|
|
20
|
-
const GLYPHS: Record<string, string> = {
|
|
21
|
-
pending: "○",
|
|
22
|
-
ready: "◇",
|
|
23
|
-
blocked: "○",
|
|
24
|
-
active: "●",
|
|
25
|
-
done: "■",
|
|
26
|
-
failed: "▲",
|
|
27
|
-
invalid: "!",
|
|
28
|
-
};
|
|
18
|
+
import type { TaskCompletion, TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
19
|
+
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
29
20
|
|
|
30
21
|
const STATUS_ACTIONS: Record<string, string[]> = {
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
todo: ["Start", "Cancel"],
|
|
23
|
+
"in-progress": ["Submit for review", "Cancel"],
|
|
24
|
+
review: ["Complete review", "Reject", "Cancel"],
|
|
25
|
+
rejected: ["Retry", "Cancel"],
|
|
33
26
|
done: [],
|
|
34
|
-
|
|
27
|
+
canceled: [],
|
|
35
28
|
};
|
|
36
29
|
|
|
37
30
|
type TaskRow = Artifact;
|
|
@@ -41,6 +34,7 @@ export interface TaskHierarchyRow {
|
|
|
41
34
|
depth: number;
|
|
42
35
|
childCount: number;
|
|
43
36
|
dependencies: string[];
|
|
37
|
+
active: boolean;
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
@@ -53,7 +47,7 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
53
47
|
if (!node) return;
|
|
54
48
|
visited.add(id);
|
|
55
49
|
const children = node.childIds.filter((childId) => byId.has(childId));
|
|
56
|
-
result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds] });
|
|
50
|
+
result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds], active: node.active === true });
|
|
57
51
|
for (const childId of children) visit(childId, depth + 1);
|
|
58
52
|
};
|
|
59
53
|
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
@@ -90,7 +84,13 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
90
84
|
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
91
85
|
if (action.type !== "action" || !action.row) continue;
|
|
92
86
|
|
|
93
|
-
const
|
|
87
|
+
const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
|
|
88
|
+
const choices = [
|
|
89
|
+
"Show details",
|
|
90
|
+
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
91
|
+
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
92
|
+
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
93
|
+
];
|
|
94
94
|
const choice = await ctx.ui.select(action.row.title, choices);
|
|
95
95
|
if (!choice) continue;
|
|
96
96
|
|
|
@@ -98,6 +98,13 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
98
98
|
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
99
99
|
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
100
100
|
await showTaskDetails(ctx, art, graph);
|
|
101
|
+
} else if (choice === "Make active") {
|
|
102
|
+
try {
|
|
103
|
+
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
|
|
104
|
+
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
105
|
+
} catch (error) {
|
|
106
|
+
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
107
|
+
}
|
|
101
108
|
} else if (choice === "Run gates") {
|
|
102
109
|
try {
|
|
103
110
|
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
|
|
@@ -107,19 +114,30 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
107
114
|
}
|
|
108
115
|
} else {
|
|
109
116
|
try {
|
|
110
|
-
const operation = choice === "Start"
|
|
117
|
+
const operation = choice === "Start"
|
|
118
|
+
? "tasks.start"
|
|
119
|
+
: choice === "Submit for review"
|
|
120
|
+
? "tasks.submit"
|
|
121
|
+
: choice === "Reject"
|
|
122
|
+
? "tasks.reject"
|
|
123
|
+
: choice === "Retry"
|
|
124
|
+
? "tasks.retry"
|
|
125
|
+
: choice === "Cancel"
|
|
126
|
+
? "tasks.cancel"
|
|
127
|
+
: "tasks.complete";
|
|
111
128
|
if (operation === "tasks.complete") {
|
|
112
129
|
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
|
|
113
130
|
action.row.status = result.artifact.status;
|
|
114
131
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
115
|
-
const
|
|
132
|
+
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
133
|
+
const focused = result.focused ? `\nActive: ${result.focused.title}` : "";
|
|
116
134
|
const blocked = result.blocked.length > 0
|
|
117
135
|
? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
118
136
|
: "";
|
|
119
137
|
ctx.ui.notify(
|
|
120
138
|
result.completed
|
|
121
|
-
? `Completed ${result.artifact.id}${
|
|
122
|
-
: `
|
|
139
|
+
? `Completed ${result.artifact.id}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`
|
|
140
|
+
: `Review rejected${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`,
|
|
123
141
|
result.completed ? "info" : "warning",
|
|
124
142
|
);
|
|
125
143
|
} else {
|
|
@@ -162,11 +180,15 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
162
180
|
|
|
163
181
|
function statusLine(): string {
|
|
164
182
|
const counts: Record<string, number> = {};
|
|
165
|
-
for (const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
for (const entry of hierarchy) counts[entry.task.status] = (counts[entry.task.status] ?? 0) + 1;
|
|
184
|
+
const parts = hierarchy.some((entry) => entry.active) ? ["▶ 1 active"] : [];
|
|
185
|
+
for (const status of ["todo", "in-progress", "review", "rejected", "done", "canceled"] as TaskStatus[]) {
|
|
186
|
+
if ((counts[status] ?? 0) > 0) {
|
|
187
|
+
const presentation = TASK_STATUS_PRESENTATION[status];
|
|
188
|
+
parts.push(`${presentation.glyph} ${counts[status]} ${presentation.label}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return parts.join(", ");
|
|
170
192
|
}
|
|
171
193
|
|
|
172
194
|
const header = {
|
|
@@ -210,14 +232,29 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
210
232
|
const row = entry.task;
|
|
211
233
|
const selected = i === selectedIndex;
|
|
212
234
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
235
|
+
const focus = entry.active ? theme.fg("accent", "▶") : " ";
|
|
213
236
|
const execution = executionById.get(row.id);
|
|
214
237
|
const state = execution?.state ?? row.status;
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
238
|
+
const presentation = TASK_STATUS_PRESENTATION[row.status as TaskStatus];
|
|
239
|
+
const glyphStyled = state === "invalid"
|
|
240
|
+
? theme.fg("error", "!")
|
|
241
|
+
: presentation
|
|
242
|
+
? theme.fg(presentation.color, presentation.glyph)
|
|
243
|
+
: theme.fg("muted", "?");
|
|
218
244
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
219
|
-
|
|
220
|
-
|
|
245
|
+
let laterSibling = false;
|
|
246
|
+
for (let candidate = i + 1; candidate < filtered.length; candidate++) {
|
|
247
|
+
if (filtered[candidate]!.depth < entry.depth) break;
|
|
248
|
+
if (filtered[candidate]!.depth === entry.depth) { laterSibling = true; break; }
|
|
249
|
+
}
|
|
250
|
+
const connector = taskTreeConnector({
|
|
251
|
+
depth: entry.depth,
|
|
252
|
+
hasChildren: entry.childCount > 0,
|
|
253
|
+
hasLaterSibling: laterSibling,
|
|
254
|
+
});
|
|
255
|
+
const node = entry.depth === 0 && entry.childCount > 0
|
|
256
|
+
? theme.fg("accent", connector)
|
|
257
|
+
: theme.fg("dim", connector);
|
|
221
258
|
const gates = (row.extra?.["gates"] as any[])?.length;
|
|
222
259
|
const relationParts: string[] = [];
|
|
223
260
|
if (execution) relationParts.push(execution.layer === null ? state : `layer ${execution.layer + 1} · ${state}`);
|
|
@@ -228,7 +265,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
228
265
|
}
|
|
229
266
|
if (gates) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
|
|
230
267
|
const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
|
|
231
|
-
lines.push(truncateToWidth(`${cursor}
|
|
268
|
+
lines.push(truncateToWidth(`${cursor}${focus} ${node} ${glyphStyled} ${title}${relationText}`, width, ""));
|
|
232
269
|
}
|
|
233
270
|
const hasScroll = start > 0 || end < filtered.length;
|
|
234
271
|
lines.push(theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter actions`));
|