@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.
package/src/types.ts ADDED
@@ -0,0 +1,137 @@
1
+ // ── Status ──
2
+
3
+ export type TaskStatus =
4
+ | "draft"
5
+ | "configured"
6
+ | "ready"
7
+ | "implementing"
8
+ | "reviewing"
9
+ | "done"
10
+ | "abandoned";
11
+
12
+ // ── Domain Records ──
13
+
14
+ export interface TaskRecord {
15
+ id: string;
16
+ title: string;
17
+ prompt: string;
18
+ profile: string;
19
+ phase: number;
20
+ dependencies: string[];
21
+ status: TaskStatus;
22
+ createdAt: string;
23
+ updatedAt: string;
24
+ }
25
+
26
+ export interface PhaseRecord {
27
+ phase: number;
28
+ status: "pending" | "active" | "completed";
29
+ completedAt?: string;
30
+ title?: string;
31
+ }
32
+
33
+ // ── Board Snapshot ──
34
+
35
+ export interface TaskBoardSnapshot {
36
+ version: 1;
37
+ tasks: TaskRecord[];
38
+ phases: PhaseRecord[];
39
+ pendingPhasePrompt?: {
40
+ phase: number;
41
+ message: string;
42
+ };
43
+ }
44
+
45
+ // ── Event Types ──
46
+
47
+ export type TaskWorkflowEvent =
48
+ | {
49
+ type: "write_tasks";
50
+ mode: "replace" | "append";
51
+ phases: Array<{
52
+ phase: number;
53
+ title: string;
54
+ tasks: Array<{
55
+ id: string;
56
+ title: string;
57
+ prompt: string;
58
+ profile: string;
59
+ phase: number;
60
+ }>;
61
+ }>;
62
+ }
63
+ | {
64
+ type: "edit_task_data";
65
+ id: string;
66
+ data: Partial<Pick<TaskRecord, "title" | "prompt" | "profile" | "phase">>;
67
+ }
68
+ | { type: "edit_task_blockers"; id: string; dependencies: string[] }
69
+ | { type: "compile_tasks" }
70
+ | { type: "claim_ready_tasks"; ids: string[] }
71
+ | {
72
+ type: "advance_task";
73
+ id: string;
74
+ from: "implementing" | "reviewing";
75
+ to: "reviewing" | "done";
76
+ }
77
+ | { type: "abandon_task"; id: string }
78
+ | { type: "clear_tasks" };
79
+
80
+ // ── Edit Input Types ──
81
+
82
+ export interface DataEdit {
83
+ id: string;
84
+ type: "data";
85
+ data: { title?: string; prompt?: string; profile?: string; phase?: number };
86
+ }
87
+
88
+ export interface BlockersEdit {
89
+ id: string;
90
+ type: "blockers";
91
+ data: { dependencies: string[] };
92
+ }
93
+
94
+ export interface AdvanceEdit {
95
+ id: string;
96
+ type: "advance";
97
+ data?: Record<string, never>;
98
+ }
99
+
100
+ export interface AbandonEdit {
101
+ id: string;
102
+ type: "abandon";
103
+ data?: Record<string, never>;
104
+ }
105
+
106
+ export type TaskEdit = DataEdit | BlockersEdit | AdvanceEdit | AbandonEdit;
107
+
108
+ // ── Constants ──
109
+
110
+ export const MAX_TASKS = 100;
111
+ export const MAX_AUTO_CONTINUE = 20;
112
+
113
+ export const CUSTOM_EVENT_TYPE = "phased-tasks:event";
114
+ export const CUSTOM_SNAPSHOT_TYPE = "phased-tasks:snapshot";
115
+
116
+ export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set(["done", "abandoned"]);
117
+ export const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set(["implementing", "reviewing"]);
118
+ export const ALL_STATUSES: ReadonlySet<TaskStatus> = new Set([
119
+ "draft",
120
+ "configured",
121
+ "ready",
122
+ "implementing",
123
+ "reviewing",
124
+ "done",
125
+ "abandoned",
126
+ ]);
127
+
128
+ /** Status → plain-text icon character */
129
+ export const STATUS_ICONS: Record<TaskStatus, string> = {
130
+ draft: "⚪",
131
+ configured: "🔵",
132
+ ready: "🟢",
133
+ implementing: "▶️",
134
+ reviewing: "🔍",
135
+ done: "✅",
136
+ abandoned: "❌",
137
+ };
@@ -0,0 +1,185 @@
1
+ import type { TaskRecord, TaskStatus, TaskBoardSnapshot } from "./types";
2
+ import { TERMINAL_STATUSES, ACTIVE_STATUSES } from "./types";
3
+
4
+ // ── String Validation ──
5
+
6
+ /** Returns true if the value is a non-empty trimmed string. */
7
+ export function isNonEmptyString(value: unknown): value is string {
8
+ return typeof value === "string" && value.trim().length > 0;
9
+ }
10
+
11
+ // ── Phase Validation ──
12
+
13
+ /** Returns true if the value is an integer >= 1. */
14
+ export function isValidPhase(value: unknown): value is number {
15
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
16
+ }
17
+
18
+ // ── Dependency Validation ──
19
+
20
+ /** Returns true if a task's dependencies have no self-reference. */
21
+ export function hasSelfDependency(taskId: string, dependencies: string[]): boolean {
22
+ return dependencies.includes(taskId);
23
+ }
24
+
25
+ /** Returns true if the dependencies array has duplicates. */
26
+ export function hasDuplicateDependencies(dependencies: string[]): boolean {
27
+ return new Set(dependencies).size !== dependencies.length;
28
+ }
29
+
30
+ /** Returns the set of dependency ids that don't exist in the task id set. */
31
+ export function findMissingDependencies(
32
+ dependencies: string[],
33
+ existingIds: Set<string>,
34
+ ): string[] {
35
+ return dependencies.filter((d) => !existingIds.has(d));
36
+ }
37
+
38
+ // ── Cycle Detection ──
39
+
40
+ /**
41
+ * Detect cycles in the task dependency graph using DFS.
42
+ * Returns an array of task ids forming a cycle, or empty array if acyclic.
43
+ */
44
+ export function detectCycle(tasks: TaskRecord[]): string[] {
45
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
46
+ const WHITE = 0,
47
+ GRAY = 1,
48
+ BLACK = 2;
49
+ const color = new Map<string, number>();
50
+ for (const t of tasks) color.set(t.id, WHITE);
51
+
52
+ const path: string[] = [];
53
+
54
+ function dfs(id: string): string[] | null {
55
+ color.set(id, GRAY);
56
+ path.push(id);
57
+ const task = taskMap.get(id);
58
+ if (task) {
59
+ for (const dep of task.dependencies) {
60
+ const depColor = color.get(dep);
61
+ if (depColor === GRAY) {
62
+ // Found cycle — extract cycle path
63
+ const cycleStart = path.indexOf(dep);
64
+ return path.slice(cycleStart);
65
+ }
66
+ if (depColor === WHITE) {
67
+ const cycle = dfs(dep);
68
+ if (cycle) return cycle;
69
+ }
70
+ }
71
+ }
72
+ path.pop();
73
+ color.set(id, BLACK);
74
+ return null;
75
+ }
76
+
77
+ for (const t of tasks) {
78
+ if (color.get(t.id) === WHITE) {
79
+ const cycle = dfs(t.id);
80
+ if (cycle) return cycle;
81
+ }
82
+ }
83
+
84
+ return [];
85
+ }
86
+
87
+ // ── Status Counts ──
88
+
89
+ /** Counts tasks by status. Returns a fully-populated Record with all statuses (zero if absent). */
90
+ export function getStatusCounts(board: TaskBoardSnapshot): Record<TaskStatus, number> {
91
+ const counts: Record<TaskStatus, number> = {
92
+ draft: 0,
93
+ configured: 0,
94
+ ready: 0,
95
+ implementing: 0,
96
+ reviewing: 0,
97
+ done: 0,
98
+ abandoned: 0,
99
+ };
100
+ for (const t of board.tasks) {
101
+ counts[t.status]++;
102
+ }
103
+ return counts;
104
+ }
105
+
106
+ // ── Board State Checks ──
107
+
108
+ /** Returns true if any task is in implementing or reviewing. */
109
+ function hasActiveTasks(board: TaskBoardSnapshot): boolean {
110
+ return board.tasks.some((t) => ACTIVE_STATUSES.has(t.status));
111
+ }
112
+
113
+ /** Returns true if any task is in a non-terminal state. */
114
+ function hasNonTerminalTasks(board: TaskBoardSnapshot): boolean {
115
+ return board.tasks.some((t) => !TERMINAL_STATUSES.has(t.status));
116
+ }
117
+
118
+ /** Returns true if there are tasks in ready, implementing, or reviewing. */
119
+ export function hasActionableTasks(board: TaskBoardSnapshot): boolean {
120
+ return board.tasks.some((t) => t.status === "ready") || hasActiveTasks(board);
121
+ }
122
+
123
+ /** Returns true if there are non-terminal tasks but none are actionable (deadlock). */
124
+ export function hasBlockedNonTerminalTasks(board: TaskBoardSnapshot): boolean {
125
+ return hasNonTerminalTasks(board) && !hasActionableTasks(board);
126
+ }
127
+
128
+ // ── Snapshot Validation ──
129
+
130
+ const VALID_TASK_STATUSES = new Set([
131
+ "draft",
132
+ "configured",
133
+ "ready",
134
+ "implementing",
135
+ "reviewing",
136
+ "done",
137
+ "abandoned",
138
+ ]);
139
+
140
+ const VALID_PHASE_STATUSES = new Set(["pending", "active", "completed"]);
141
+
142
+ function isValidTask(t: unknown): boolean {
143
+ if (typeof t !== "object" || t === null) return false;
144
+ const task = t as Record<string, unknown>;
145
+ return (
146
+ typeof task.id === "string" &&
147
+ typeof task.title === "string" &&
148
+ typeof task.status === "string" &&
149
+ VALID_TASK_STATUSES.has(task.status) &&
150
+ typeof task.phase === "number" &&
151
+ Array.isArray(task.dependencies) &&
152
+ (task.dependencies as unknown[]).every((d) => typeof d === "string")
153
+ );
154
+ }
155
+
156
+ function isValidPhaseRecord(p: unknown): boolean {
157
+ if (typeof p !== "object" || p === null) return false;
158
+ const phase = p as Record<string, unknown>;
159
+ return (
160
+ typeof phase.phase === "number" &&
161
+ typeof phase.status === "string" &&
162
+ VALID_PHASE_STATUSES.has(phase.status)
163
+ );
164
+ }
165
+
166
+ /** Type guard for a valid TaskBoardSnapshot. Checks version field, structure, and task/phase validity. */
167
+ export function isValidSnapshot(data: unknown): data is TaskBoardSnapshot {
168
+ if (typeof data !== "object" || data === null) return false;
169
+ const obj = data as Record<string, unknown>;
170
+ if (obj.version !== 1 || !Array.isArray(obj.tasks) || !Array.isArray(obj.phases)) return false;
171
+ if (!(obj.tasks as unknown[]).every(isValidTask)) return false;
172
+ if (!(obj.phases as unknown[]).every(isValidPhaseRecord)) return false;
173
+ return true;
174
+ }
175
+
176
+ // ── Deep Clone ──
177
+
178
+ /**
179
+ * Creates a deep copy of a TaskBoardSnapshot.
180
+ * Uses JSON roundtrip (instead of structuredClone) because benchmarks show it's
181
+ * faster for plain-object graphs without special types.
182
+ */
183
+ export function cloneBoard(board: TaskBoardSnapshot): TaskBoardSnapshot {
184
+ return JSON.parse(JSON.stringify(board)) as TaskBoardSnapshot;
185
+ }