@harms-haus/pi-tasks 0.1.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.
@@ -0,0 +1,201 @@
1
+ import type { TaskBoardSnapshot, TaskRecord, TaskStatus } from "./types";
2
+ import { STATUS_ICONS } from "./types";
3
+ import { getStatusCounts } from "./validation";
4
+
5
+ // ── Plain-Text Formatting (for LLM tool output) ──
6
+
7
+ /** Returns a phase label string including the title if available. */
8
+ export function phaseLabel(board: TaskBoardSnapshot, phaseNum: number): string {
9
+ const rec = board.phases.find((p) => p.phase === phaseNum);
10
+ return rec?.title ? `Phase ${phaseNum}: ${rec.title}` : `Phase ${phaseNum}`;
11
+ }
12
+
13
+ /** Returns the plain-text icon for a task status. */
14
+ function getStatusIcon(status: TaskStatus): string {
15
+ return STATUS_ICONS[status];
16
+ }
17
+
18
+ /** Format a single task as a plain-text line. */
19
+ function formatTaskLine(task: TaskRecord): string {
20
+ return `${getStatusIcon(task.status)} ${task.id}: ${task.title}`;
21
+ }
22
+
23
+ /** Format the full board as a plain-text summary for LLM consumption. */
24
+ export function formatBoardText(
25
+ board: TaskBoardSnapshot,
26
+ options?: { activePhaseOnly?: boolean; counts?: Record<TaskStatus, number> },
27
+ ): string {
28
+ if (board.tasks.length === 0) return "No tasks on the board.";
29
+
30
+ const lines: string[] = ["Task Board:", ""];
31
+
32
+ const activePhaseOnly = options?.activePhaseOnly === true;
33
+ const activePhaseRecord = board.phases.find((p) => p.status === "active");
34
+ const useActiveOnly = activePhaseOnly && activePhaseRecord !== undefined;
35
+
36
+ // Determine which phases to render
37
+ const phases = useActiveOnly
38
+ ? [activePhaseRecord.phase]
39
+ : [...new Set(board.tasks.map((t) => t.phase))].sort((a, b) => a - b);
40
+
41
+ for (const phase of phases) {
42
+ lines.push(`─── ${phaseLabel(board, phase)} ───`);
43
+ const phaseTasks = board.tasks.filter((t) => t.phase === phase);
44
+ for (const task of phaseTasks) {
45
+ let line = formatTaskLine(task);
46
+ if (task.dependencies.length > 0) {
47
+ line += ` → depends on ${task.dependencies.join(", ")}`;
48
+ }
49
+ lines.push(line);
50
+ }
51
+ lines.push("");
52
+ }
53
+
54
+ // Summary line (always across ALL phases)
55
+ const counts = options?.counts ?? getStatusCounts(board);
56
+ const parts: string[] = [];
57
+ for (const [status, count] of Object.entries(counts)) {
58
+ if (count > 0) parts.push(`${count} ${status}`);
59
+ }
60
+ lines.push(`Summary: ${parts.join(", ")}`);
61
+
62
+ return lines.join("\n");
63
+ }
64
+
65
+ /** Format a short summary for tool output headers. */
66
+ export function formatSummaryLine(
67
+ board: TaskBoardSnapshot,
68
+ counts?: Record<TaskStatus, number>,
69
+ ): string {
70
+ const total = board.tasks.length;
71
+ const done = counts
72
+ ? counts.done + counts.abandoned
73
+ : board.tasks.filter((t) => t.status === "done" || t.status === "abandoned").length;
74
+ const activePhase = board.phases.find((p) => p.status === "active");
75
+ return activePhase
76
+ ? `${phaseLabel(board, activePhase.phase)} · ${done}/${total} done`
77
+ : `${done}/${total} done`;
78
+ }
79
+
80
+ /** Format claimed task details with prompt preview. */
81
+ export function formatClaimedTaskDetails(tasks: TaskRecord[]): string {
82
+ return tasks
83
+ .map((t) => {
84
+ const lines = [`${getStatusIcon(t.status)} ${t.id}: ${t.title} (${t.profile})`];
85
+ const promptLines = t.prompt.split("\n");
86
+ const shown = promptLines.slice(0, 3);
87
+ lines.push(...shown);
88
+ if (promptLines.length > 3) {
89
+ lines.push(" ... (truncated)");
90
+ }
91
+ return lines.join("\n");
92
+ })
93
+ .join("\n\n");
94
+ }
95
+
96
+ /** Format the hidden context message for before_agent_start injection. */
97
+ export function formatHiddenContext(board: TaskBoardSnapshot): string {
98
+ const lines: string[] = ["[PHASED TASKS ACTIVE]", ""];
99
+
100
+ const activePhase = board.phases.find((p) => p.status === "active");
101
+ lines.push(`Active Phase: ${activePhase ? phaseLabel(board, activePhase.phase) : "none"}`);
102
+
103
+ // Counts by status
104
+ const counts = getStatusCounts(board);
105
+ lines.push(
106
+ `Status: ${Object.entries(counts)
107
+ .filter(([, c]) => c > 0)
108
+ .map(([s, c]) => `${c} ${s}`)
109
+ .join(", ")}`,
110
+ );
111
+
112
+ // Currently claimed tasks
113
+ const claimed = board.tasks.filter(
114
+ (t) => t.status === "implementing" || t.status === "reviewing",
115
+ );
116
+ if (claimed.length > 0) {
117
+ lines.push("");
118
+ lines.push("Currently claimed:");
119
+ for (const t of claimed) {
120
+ lines.push(` ${getStatusIcon(t.status)} [${t.id}] ${t.title}`);
121
+ }
122
+ }
123
+
124
+ // Non-terminal tasks
125
+ lines.push("");
126
+ lines.push("Remaining tasks:");
127
+ const nonTerminal = board.tasks.filter((t) => t.status !== "done" && t.status !== "abandoned");
128
+ for (const t of nonTerminal) {
129
+ lines.push(` ${formatTaskLine(t)}`);
130
+ }
131
+
132
+ // Recently completed (up to 10)
133
+ const terminal = board.tasks.filter((t) => t.status === "done" || t.status === "abandoned");
134
+ if (terminal.length > 0) {
135
+ lines.push("");
136
+ lines.push("Recently completed:");
137
+ const sorted = [...terminal].sort(
138
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
139
+ );
140
+ const recent = sorted.slice(0, 10);
141
+ if (terminal.length > 10) {
142
+ lines.push(` ... and ${terminal.length - 10} more terminal tasks`);
143
+ }
144
+ for (const t of recent) {
145
+ lines.push(` ${formatTaskLine(t)}`);
146
+ }
147
+ }
148
+
149
+ lines.push("");
150
+ lines.push(
151
+ "Workflow: write_tasks → edit_tasks (blockers/data) → compile_tasks → get_ready_tasks → advance_tasks → done",
152
+ );
153
+
154
+ return lines.join("\n");
155
+ }
156
+
157
+ /** Format the auto-continue prompt. */
158
+ export function formatContinuePrompt(board: TaskBoardSnapshot): string {
159
+ const ready = board.tasks.filter((t) => t.status === "ready");
160
+ const active = board.tasks.filter((t) => t.status === "implementing" || t.status === "reviewing");
161
+
162
+ if (ready.length > 0 || active.length > 0) {
163
+ const lines: string[] = ["Tasks remain. Continue working on the phased task board."];
164
+ if (active.length > 0) {
165
+ lines.push("");
166
+ lines.push("Currently claimed:");
167
+ for (const t of active) {
168
+ lines.push(` [${t.id}] ${t.title} (${t.status})`);
169
+ }
170
+ }
171
+ if (ready.length > 0) {
172
+ lines.push("");
173
+ lines.push(`Ready to claim: ${ready.length} task(s). Call get_ready_tasks to claim them.`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ // Deadlock
179
+ const nonTerminal = board.tasks.filter((t) => t.status !== "done" && t.status !== "abandoned");
180
+ if (nonTerminal.length > 0) {
181
+ return [
182
+ "The task board is blocked — no tasks are ready, implementing, or reviewing, but tasks remain.",
183
+ "Inspect dependencies and phase gating. Use edit_tasks to resolve blockers, then compile_tasks.",
184
+ "",
185
+ "Blocked tasks:",
186
+ ...nonTerminal.map(
187
+ (t) => ` [${t.id}] ${t.title} (${t.status}, ${phaseLabel(board, t.phase)})`,
188
+ ),
189
+ ].join("\n");
190
+ }
191
+
192
+ return "";
193
+ }
194
+
195
+ /** Format the "all done" terminal message. */
196
+ export function formatAllDoneMessage(board: TaskBoardSnapshot): string {
197
+ if (board.phases.length === 0) return "All tasks resolved.";
198
+ const lastPhaseRecord = board.phases[board.phases.length - 1];
199
+ if (!lastPhaseRecord) return "All tasks resolved.";
200
+ return `All tasks resolved. ${phaseLabel(board, lastPhaseRecord.phase)} complete.`;
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerMessageRenderers } from "./renderers";
3
+ import { registerEventHandlers } from "./events";
4
+ import {
5
+ createWriteTasksTool,
6
+ createEditTasksTool,
7
+ createCompileTasksTool,
8
+ createClearTasksTool,
9
+ createGetReadyTasksTool,
10
+ createAdvanceTasksTool,
11
+ } from "./tools";
12
+
13
+ export default function (pi: ExtensionAPI): void {
14
+ registerMessageRenderers(pi);
15
+ registerEventHandlers(pi);
16
+
17
+ pi.registerTool(createWriteTasksTool(pi));
18
+ pi.registerTool(createEditTasksTool(pi));
19
+ pi.registerTool(createCompileTasksTool(pi));
20
+ pi.registerTool(createClearTasksTool(pi));
21
+ pi.registerTool(createGetReadyTasksTool(pi));
22
+ pi.registerTool(createAdvanceTasksTool(pi));
23
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ export function registerMessageRenderers(pi: ExtensionAPI): void {
5
+ pi.registerMessageRenderer("phased-tasks-context", (message, _opts, theme) => {
6
+ return new Text(
7
+ theme.fg("accent", "📋 ") +
8
+ theme.fg(
9
+ "dim",
10
+ typeof message.content === "string" ? message.content : JSON.stringify(message.content),
11
+ ),
12
+ 0,
13
+ 0,
14
+ );
15
+ });
16
+
17
+ pi.registerMessageRenderer("phased-tasks-notice", (message, _opts, theme) => {
18
+ return new Text(
19
+ theme.fg("warning", "⚠ ") +
20
+ theme.fg(
21
+ "text",
22
+ typeof message.content === "string" ? message.content : JSON.stringify(message.content),
23
+ ),
24
+ 0,
25
+ 0,
26
+ );
27
+ });
28
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+
4
+ // ── Schemas ──
5
+
6
+ export const WriteTasksParams = Type.Object({
7
+ mode: StringEnum(["replace", "append"], {
8
+ description: "'replace' clears the board before writing; 'append' adds to existing tasks",
9
+ }),
10
+ phases: Type.Array(
11
+ Type.Object({
12
+ title: Type.String({ description: "Short phase title" }),
13
+ tasks: Type.Array(
14
+ Type.Object({
15
+ title: Type.String({ description: "Short task title" }),
16
+ prompt: Type.String({ description: "Detailed implementation instructions" }),
17
+ profile: Type.String({ description: "Agent profile name for task delegation" }),
18
+ }),
19
+ { description: "Tasks in this phase (at least 1)", minItems: 1 },
20
+ ),
21
+ }),
22
+ { description: "Phases to write to the board", minItems: 1 },
23
+ ),
24
+ });
25
+
26
+ export const EditTasksParams = Type.Object({
27
+ tasks: Type.Array(
28
+ Type.Union([
29
+ Type.Object({
30
+ id: Type.String(),
31
+ type: StringEnum(["data"]),
32
+ data: Type.Object({
33
+ title: Type.Optional(Type.String()),
34
+ prompt: Type.Optional(Type.String()),
35
+ profile: Type.Optional(Type.String()),
36
+ phase: Type.Optional(Type.Integer()),
37
+ }),
38
+ }),
39
+ Type.Object({
40
+ id: Type.String(),
41
+ type: StringEnum(["blockers"]),
42
+ data: Type.Object({
43
+ dependencies: Type.Array(Type.String()),
44
+ }),
45
+ }),
46
+ Type.Object({
47
+ id: Type.String(),
48
+ type: StringEnum(["abandon"]),
49
+ }),
50
+ ]),
51
+ { maxItems: 50 },
52
+ ),
53
+ });
54
+
55
+ export const CompileTasksParams = Type.Object({});
56
+
57
+ export const ClearTasksParams = Type.Object({});
58
+
59
+ export const GetReadyTasksParams = Type.Object({
60
+ count: Type.Integer({ description: "Number of tasks to claim (>= 1)", minimum: 1 }),
61
+ });
62
+
63
+ export const AdvanceTasksParams = Type.Object({
64
+ ids: Type.Array(Type.String(), { description: "Task IDs to advance", maxItems: 50 }),
65
+ });
package/src/state.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskBoardSnapshot } from "./types";
3
+ import { CUSTOM_EVENT_TYPE, CUSTOM_SNAPSHOT_TYPE } from "./types";
4
+ import { createEmptyBoard } from "./engine";
5
+ import { isValidSnapshot, cloneBoard, getStatusCounts } from "./validation";
6
+ import { phaseLabel } from "./formatting";
7
+
8
+ // ── Mutable State ──
9
+
10
+ let board: TaskBoardSnapshot = createEmptyBoard();
11
+ let autoContinueCount = 0;
12
+ let lastToolWasAdvance = false;
13
+
14
+ // ── State Accessors ──
15
+
16
+ /** Returns a deep copy of the current board. */
17
+ export function getBoard(): TaskBoardSnapshot {
18
+ return cloneBoard(board);
19
+ }
20
+
21
+ /** Replaces the board state. Resets auto-continue counter. */
22
+ export function setBoard(newBoard: TaskBoardSnapshot): void {
23
+ board = cloneBoard(newBoard);
24
+ autoContinueCount = 0;
25
+ }
26
+
27
+ /** Replaces the board state without resetting the auto-continue counter. Used internally by the auto-continue loop. */
28
+ export function setBoardQuiet(newBoard: TaskBoardSnapshot): void {
29
+ board = cloneBoard(newBoard);
30
+ }
31
+
32
+ /** Returns a readonly reference to the current board (no clone — caller must not mutate). */
33
+ export function getBoardRef(): Readonly<TaskBoardSnapshot> {
34
+ return board;
35
+ }
36
+
37
+ /** Increments and returns the auto-continue counter. */
38
+ export function incrementAutoContinue(): number {
39
+ return ++autoContinueCount;
40
+ }
41
+
42
+ /** Returns whether the last tool result was from advance_tasks. */
43
+ export function getLastToolWasAdvance(): boolean {
44
+ return lastToolWasAdvance;
45
+ }
46
+
47
+ /** Set the last-tool-was-advance flag. */
48
+ export function setLastToolWasAdvance(value: boolean): void {
49
+ lastToolWasAdvance = value;
50
+ }
51
+
52
+ /** Resets all mutable state. For testing only. */
53
+ export function resetState(): void {
54
+ board = createEmptyBoard();
55
+ autoContinueCount = 0;
56
+ lastToolWasAdvance = false;
57
+ }
58
+
59
+ // ── State Reconstruction ──
60
+
61
+ /**
62
+ * Reconstructs board state from session history.
63
+ * Scans the branch in reverse to find the latest phased-tasks:snapshot custom entry.
64
+ * Falls back to empty board if no valid snapshot found.
65
+ */
66
+ export function reconstructState(ctx: ExtensionContext): TaskBoardSnapshot {
67
+ const branch = ctx.sessionManager.getBranch();
68
+
69
+ for (let i = branch.length - 1; i >= 0; i--) {
70
+ const entry = branch[i];
71
+ if (!entry) continue;
72
+ if (entry.type !== "custom") continue;
73
+ if ((entry as { customType?: string }).customType !== CUSTOM_SNAPSHOT_TYPE) continue;
74
+ const data = (entry as { data?: unknown }).data;
75
+ if (data && isValidSnapshot(data)) {
76
+ return cloneBoard(data);
77
+ }
78
+ }
79
+
80
+ return createEmptyBoard();
81
+ }
82
+
83
+ // ── Persistence Helpers ──
84
+
85
+ /** Append both an event and a snapshot entry. */
86
+ export function persistEntries(
87
+ pi: ExtensionAPI,
88
+ event: unknown,
89
+ snapshot: TaskBoardSnapshot,
90
+ ): void {
91
+ pi.appendEntry(CUSTOM_EVENT_TYPE, event);
92
+ pi.appendEntry(CUSTOM_SNAPSHOT_TYPE, snapshot);
93
+ }
94
+
95
+ // ── UI Sync ──
96
+
97
+ /** Updates the status bar to reflect current board state. */
98
+ export function updateUI(ctx: ExtensionContext, snapshot: Readonly<TaskBoardSnapshot>): void {
99
+ if (!ctx.hasUI) return;
100
+
101
+ if (snapshot.tasks.length === 0) {
102
+ ctx.ui.setStatus("til-done", undefined);
103
+ ctx.ui.setStatus("til-done-active", undefined);
104
+ return;
105
+ }
106
+
107
+ const counts = getStatusCounts(snapshot);
108
+
109
+ const done = counts.done + counts.abandoned;
110
+ const total = snapshot.tasks.length;
111
+
112
+ const activePhase = snapshot.phases.find((p) => p.status === "active");
113
+ const label = activePhase ? phaseLabel(snapshot, activePhase.phase) : "No active phase";
114
+
115
+ ctx.ui.setStatus("til-done", `${done}/${total} - ${label}`);
116
+
117
+ const activeLines: string[] = [];
118
+ for (const t of snapshot.tasks) {
119
+ if (t.status === "implementing" || t.status === "reviewing") {
120
+ activeLines.push(`[${t.id}] ${t.title}`);
121
+ }
122
+ }
123
+ ctx.ui.setStatus("til-done-active", activeLines.length > 0 ? activeLines.join("\n") : undefined);
124
+ }