@felan-ai/ext-tasks 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felan contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,12 @@
1
+ @felan-ai/ext-tasks
2
+
3
+ Its interaction design was informed by the MIT-licensed pi-todo-write package
4
+ from https://github.com/mslavov/pi-extensions at source commit
5
+ 9571293d422db11de893fa80ed0fc3e39945c657. No source code was copied.
6
+
7
+ The dependency and readiness model was informed by the open-source Beads task
8
+ tracker. This package does not include Beads source code or invoke its CLI.
9
+
10
+ This package uses TypeBox 1.1.38 from
11
+ https://github.com/sinclairzx81/typebox, licensed under the MIT License.
12
+ Copyright (c) 2017-2026 Haydn Paterson.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @felan-ai/ext-tasks
2
+
3
+ Dependency-aware task tracking scoped to one Felan root session. The extension
4
+ registers `TaskCreate`, `TaskUpdate`, `TaskList`, and `TaskGet`. Tasks have stable
5
+ IDs, hard prerequisite edges, priorities, acceptance criteria, ownership,
6
+ handoff notes, and terminal results.
7
+
8
+ Task state lives under `<session-storage>/tasks/state.json`. The root session and
9
+ all of its subagents use the same `AgentRuntime.storage('session')` namespace,
10
+ so every worker sees the same graph without a task-specific subagent protocol.
11
+ Mutations are serialized per storage root within a Felan host process, and
12
+ `TaskUpdate` atomically claims a ready task for its calling session when setting
13
+ `status: "in_progress"`. A cloud host that distributes one root session across
14
+ multiple processes must provide equivalent root-scoped serialization.
15
+
16
+ The task graph is execution state rather than an issue tracker. It has no
17
+ project backlog, remote synchronization, comments, labels, estimates, or due
18
+ dates. Work that must outlive the root session belongs in Beads, Jira, Linear,
19
+ or another persistent tracking system.
20
+
21
+ ## Tools
22
+
23
+ - `TaskCreate` creates a pending task and returns its stable ID.
24
+ - `TaskUpdate` edits metadata and dependencies or changes lifecycle status.
25
+ - `TaskList` returns the current, ready, active, blocked, pending, completed, or
26
+ full task view.
27
+ - `TaskGet` returns one task with prerequisite and dependent details.
28
+
29
+ Only completed prerequisites satisfy a dependency. Dependency cycles are
30
+ rejected. Multiple sessions may own work concurrently, while each session may
31
+ claim at most one task at a time. A session cannot change another session's
32
+ active task unless it explicitly requests stale-claim recovery with `force`.
33
+
34
+ Local TUI sessions expose `/tasks` and `Ctrl+Shift+T` for list, detail, and graph
35
+ views. Headless and cloud sessions use the same tools and storage without
36
+ registering TUI controls.
37
+
38
+ ## Development
39
+
40
+ Source: `packages/ext-tasks` in <https://github.com/felan-ai/felan>.
41
+
42
+ ```sh
43
+ corepack enable
44
+ pnpm install --frozen-lockfile
45
+ pnpm --filter @felan-ai/ext-tasks build
46
+ pnpm --filter @felan-ai/ext-tasks type-check
47
+ pnpm --filter @felan-ai/ext-tasks test
48
+ ```
@@ -0,0 +1,62 @@
1
+ export declare const TASK_STATUS_VALUES: readonly ["pending", "in_progress", "blocked", "completed", "cancelled"];
2
+ export declare const TASK_VIEW_VALUES: readonly ["current", "ready", "active", "blocked", "pending", "completed", "all"];
3
+ export type TaskStatus = typeof TASK_STATUS_VALUES[number];
4
+ export type TaskView = typeof TASK_VIEW_VALUES[number];
5
+ export type TaskAvailability = 'ready' | 'waiting' | 'in_progress' | 'blocked' | 'completed' | 'cancelled';
6
+ export interface Task {
7
+ readonly id: string;
8
+ readonly title: string;
9
+ readonly description?: string;
10
+ readonly acceptanceCriteria?: string;
11
+ readonly priority: number;
12
+ readonly status: TaskStatus;
13
+ readonly blockedBy: readonly string[];
14
+ readonly ownerSessionId?: string;
15
+ readonly claimedAt?: string;
16
+ readonly notes?: string;
17
+ readonly result?: string;
18
+ readonly blockedReason?: string;
19
+ readonly createdAt: string;
20
+ readonly updatedAt: string;
21
+ readonly completedAt?: string;
22
+ }
23
+ export interface TaskState {
24
+ readonly schemaVersion: 1;
25
+ readonly revision: number;
26
+ readonly tasks: readonly Task[];
27
+ }
28
+ export interface CreateTaskInput {
29
+ readonly title: string;
30
+ readonly description?: string;
31
+ readonly acceptanceCriteria?: string;
32
+ readonly priority?: number;
33
+ readonly blockedBy?: readonly string[];
34
+ }
35
+ export interface UpdateTaskInput {
36
+ readonly taskId: string;
37
+ readonly force?: boolean;
38
+ readonly title?: string;
39
+ readonly description?: string;
40
+ readonly acceptanceCriteria?: string;
41
+ readonly priority?: number;
42
+ readonly status?: TaskStatus;
43
+ readonly addBlockedBy?: readonly string[];
44
+ readonly removeBlockedBy?: readonly string[];
45
+ readonly notes?: string;
46
+ readonly result?: string;
47
+ readonly blockedReason?: string;
48
+ }
49
+ export interface TaskMutationResult {
50
+ readonly state: TaskState;
51
+ readonly task: Task;
52
+ }
53
+ export interface TaskCounts {
54
+ readonly total: number;
55
+ readonly ready: number;
56
+ readonly active: number;
57
+ readonly blocked: number;
58
+ readonly pending: number;
59
+ readonly completed: number;
60
+ readonly cancelled: number;
61
+ }
62
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ export const TASK_STATUS_VALUES = [
2
+ 'pending',
3
+ 'in_progress',
4
+ 'blocked',
5
+ 'completed',
6
+ 'cancelled',
7
+ ];
8
+ export const TASK_VIEW_VALUES = [
9
+ 'current',
10
+ 'ready',
11
+ 'active',
12
+ 'blocked',
13
+ 'pending',
14
+ 'completed',
15
+ 'all',
16
+ ];
17
+ //# sourceMappingURL=contracts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contracts.js","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC,SAAS;IACT,aAAa;IACb,SAAS;IACT,WAAW;IACX,WAAW;CACH,CAAC;AAEX,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,SAAS;IACT,OAAO;IACP,QAAQ;IACR,SAAS;IACT,SAAS;IACT,WAAW;IACX,KAAK;CACG,CAAC"}
@@ -0,0 +1,21 @@
1
+ import type { CreateTaskInput, Task, TaskAvailability, TaskCounts, TaskMutationResult, TaskState, TaskView, UpdateTaskInput } from './contracts.js';
2
+ export declare const MAX_TASKS = 200;
3
+ export declare const MAX_DEPENDENCIES = 32;
4
+ export declare const TASK_ID_PATTERN: RegExp;
5
+ export declare class TaskGraphError extends Error {
6
+ readonly code: string;
7
+ constructor(code: string, message: string);
8
+ }
9
+ export declare function emptyTaskState(): TaskState;
10
+ export declare function cloneTaskState(state: TaskState): TaskState;
11
+ export declare function createTask(state: TaskState, input: CreateTaskInput, id: string, now: string): TaskMutationResult;
12
+ export declare function updateTask(state: TaskState, input: UpdateTaskInput, actorSessionId: string, now: string): TaskMutationResult;
13
+ export declare function getTask(state: TaskState, id: string): Task;
14
+ export declare function taskDependents(state: TaskState, id: string): Task[];
15
+ export declare function taskAvailability(task: Task, state: TaskState): TaskAvailability;
16
+ export declare function incompleteBlockers(task: Task, state: TaskState): Task[];
17
+ export declare function listTasks(state: TaskState, view: TaskView): Task[];
18
+ export declare function taskCounts(state: TaskState): TaskCounts;
19
+ export declare function hasOpenTasks(state: TaskState): boolean;
20
+ export declare function parseTaskState(value: unknown): TaskState;
21
+ //# sourceMappingURL=graph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,IAAI,EACJ,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,SAAS,EAET,QAAQ,EACR,eAAe,EAChB,MAAM,gBAAgB,CAAC;AAGxB,eAAO,MAAM,SAAS,MAAM,CAAC;AAC7B,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,eAAe,QAAqB,CAAC;AAIlD,qBAAa,cAAe,SAAQ,KAAK;IAErC,QAAQ,CAAC,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAKlB;AAED,wBAAgB,cAAc,IAAI,SAAS,CAE1C;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,CAM1D;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,eAAe,EACtB,EAAE,EAAE,MAAM,EACV,GAAG,EAAE,MAAM,GACV,kBAAkB,CA2BpB;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,eAAe,EACtB,cAAc,EAAE,MAAM,EACtB,GAAG,EAAE,MAAM,GACV,kBAAkB,CAmFpB;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAI1D;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,CAInE;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,GAAG,gBAAgB,CAM/E;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,GAAG,IAAI,EAAE,CAKvE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,EAAE,CAKlE;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CAoBvD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAEtD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAmBxD"}
package/dist/graph.js ADDED
@@ -0,0 +1,447 @@
1
+ import { TASK_STATUS_VALUES } from './contracts.js';
2
+ export const MAX_TASKS = 200;
3
+ export const MAX_DEPENDENCIES = 32;
4
+ export const TASK_ID_PATTERN = /^T-[A-Z0-9]{6}$/u;
5
+ const TASK_STATUSES = new Set(TASK_STATUS_VALUES);
6
+ export class TaskGraphError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(`Task error: ${code} — ${message}`);
10
+ this.code = code;
11
+ this.name = 'TaskGraphError';
12
+ }
13
+ }
14
+ export function emptyTaskState() {
15
+ return { schemaVersion: 1, revision: 0, tasks: [] };
16
+ }
17
+ export function cloneTaskState(state) {
18
+ return {
19
+ schemaVersion: 1,
20
+ revision: state.revision,
21
+ tasks: state.tasks.map((task) => ({ ...task, blockedBy: [...task.blockedBy] })),
22
+ };
23
+ }
24
+ export function createTask(state, input, id, now) {
25
+ if (state.tasks.length >= MAX_TASKS) {
26
+ throw new TaskGraphError('task_limit', `A session task graph may contain at most ${MAX_TASKS} tasks`);
27
+ }
28
+ if (!TASK_ID_PATTERN.test(id))
29
+ throw new TaskGraphError('invalid_id', `Invalid task id: ${id}`);
30
+ if (state.tasks.some((task) => task.id === id))
31
+ throw new TaskGraphError('duplicate_id', `Task already exists: ${id}`);
32
+ const blockedBy = uniqueIds(input.blockedBy ?? []);
33
+ const task = {
34
+ id,
35
+ title: requiredText(input.title, 'title'),
36
+ priority: priorityValue(input.priority ?? 2),
37
+ status: 'pending',
38
+ blockedBy,
39
+ createdAt: now,
40
+ updatedAt: now,
41
+ ...(input.description === undefined ? {} : { description: requiredText(input.description, 'description') }),
42
+ ...(input.acceptanceCriteria === undefined
43
+ ? {}
44
+ : { acceptanceCriteria: requiredText(input.acceptanceCriteria, 'acceptance criteria') }),
45
+ };
46
+ const tasks = [...state.tasks, task];
47
+ assertGraphValid(tasks);
48
+ return {
49
+ state: { schemaVersion: 1, revision: state.revision + 1, tasks },
50
+ task,
51
+ };
52
+ }
53
+ export function updateTask(state, input, actorSessionId, now) {
54
+ const index = state.tasks.findIndex((task) => task.id === input.taskId);
55
+ if (index < 0)
56
+ throw new TaskGraphError('not_found', `Task not found: ${input.taskId}`);
57
+ const actor = requiredText(actorSessionId, 'session id');
58
+ const original = state.tasks[index];
59
+ let task = { ...original, blockedBy: [...original.blockedBy] };
60
+ if (original.status === 'in_progress'
61
+ && original.ownerSessionId !== actor
62
+ && input.force !== true) {
63
+ throw new TaskGraphError('not_owner', `${original.id} is claimed by ${original.ownerSessionId}`);
64
+ }
65
+ if (original.status === 'completed' && input.status !== undefined && input.status !== 'completed') {
66
+ const protectedDependents = state.tasks.filter((entry) => (entry.blockedBy.includes(original.id)
67
+ && (entry.status === 'in_progress' || entry.status === 'completed')));
68
+ if (protectedDependents.length > 0) {
69
+ throw new TaskGraphError('dependent_started', `Reopen dependent tasks before reopening ${original.id}: ${protectedDependents.map((entry) => entry.id).join(', ')}`);
70
+ }
71
+ }
72
+ if (input.title !== undefined)
73
+ task = { ...task, title: requiredText(input.title, 'title') };
74
+ if (input.description !== undefined) {
75
+ task = { ...task, description: requiredText(input.description, 'description') };
76
+ }
77
+ if (input.acceptanceCriteria !== undefined) {
78
+ task = {
79
+ ...task,
80
+ acceptanceCriteria: requiredText(input.acceptanceCriteria, 'acceptance criteria'),
81
+ };
82
+ }
83
+ if (input.priority !== undefined)
84
+ task = { ...task, priority: priorityValue(input.priority) };
85
+ if (input.notes !== undefined)
86
+ task = { ...task, notes: requiredText(input.notes, 'notes') };
87
+ const dependencyChange = input.addBlockedBy !== undefined || input.removeBlockedBy !== undefined;
88
+ if (dependencyChange && isTerminal(original.status) && input.status !== 'pending') {
89
+ throw new TaskGraphError('terminal_task', 'Reopen a completed or cancelled task before changing its dependencies');
90
+ }
91
+ const additions = uniqueIds(input.addBlockedBy ?? []);
92
+ const removals = uniqueIds(input.removeBlockedBy ?? []);
93
+ const overlap = additions.find((id) => removals.includes(id));
94
+ if (overlap)
95
+ throw new TaskGraphError('dependency_conflict', `${overlap} cannot be added and removed together`);
96
+ const blockedBy = task.blockedBy.filter((id) => !removals.includes(id));
97
+ for (const id of additions) {
98
+ if (!blockedBy.includes(id))
99
+ blockedBy.push(id);
100
+ }
101
+ task = { ...task, blockedBy };
102
+ if (input.result !== undefined && input.status !== 'completed' && original.status !== 'completed') {
103
+ throw new TaskGraphError('invalid_result', 'A result can only be written while completing a task');
104
+ }
105
+ if (input.blockedReason !== undefined && input.status !== 'blocked' && original.status !== 'blocked') {
106
+ throw new TaskGraphError('invalid_blocker', 'A blocked reason requires blocked status');
107
+ }
108
+ if (input.status !== undefined) {
109
+ task = transitionTask(state, task, input, actor, now);
110
+ }
111
+ else {
112
+ if (input.result !== undefined)
113
+ task = { ...task, result: requiredText(input.result, 'result') };
114
+ if (input.blockedReason !== undefined) {
115
+ task = { ...task, blockedReason: requiredText(input.blockedReason, 'blocked reason') };
116
+ }
117
+ }
118
+ const candidateTasks = state.tasks.map((entry, entryIndex) => entryIndex === index ? task : entry);
119
+ assertGraphValid(candidateTasks);
120
+ if (task.status === 'in_progress' || task.status === 'completed')
121
+ assertDependenciesCompleted(task, candidateTasks);
122
+ const unchanged = JSON.stringify({ ...task, updatedAt: original.updatedAt }) === JSON.stringify(original);
123
+ if (unchanged)
124
+ throw new TaskGraphError('no_changes', `No changes were supplied for ${task.id}`);
125
+ task = { ...task, updatedAt: now };
126
+ const tasks = state.tasks.map((entry, entryIndex) => entryIndex === index ? task : entry);
127
+ return {
128
+ state: { schemaVersion: 1, revision: state.revision + 1, tasks },
129
+ task,
130
+ };
131
+ }
132
+ export function getTask(state, id) {
133
+ const task = state.tasks.find((entry) => entry.id === id);
134
+ if (!task)
135
+ throw new TaskGraphError('not_found', `Task not found: ${id}`);
136
+ return { ...task, blockedBy: [...task.blockedBy] };
137
+ }
138
+ export function taskDependents(state, id) {
139
+ return state.tasks
140
+ .filter((task) => task.blockedBy.includes(id))
141
+ .map((task) => ({ ...task, blockedBy: [...task.blockedBy] }));
142
+ }
143
+ export function taskAvailability(task, state) {
144
+ if (task.status === 'in_progress')
145
+ return 'in_progress';
146
+ if (task.status === 'blocked')
147
+ return 'blocked';
148
+ if (task.status === 'completed')
149
+ return 'completed';
150
+ if (task.status === 'cancelled')
151
+ return 'cancelled';
152
+ return incompleteBlockers(task, state).length === 0 ? 'ready' : 'waiting';
153
+ }
154
+ export function incompleteBlockers(task, state) {
155
+ const tasks = new Map(state.tasks.map((entry) => [entry.id, entry]));
156
+ return task.blockedBy
157
+ .map((id) => tasks.get(id))
158
+ .filter((entry) => entry !== undefined && entry.status !== 'completed');
159
+ }
160
+ export function listTasks(state, view) {
161
+ return state.tasks
162
+ .filter((task) => matchesView(task, state, view))
163
+ .map((task) => ({ ...task, blockedBy: [...task.blockedBy] }))
164
+ .sort((left, right) => compareTasks(left, right, state));
165
+ }
166
+ export function taskCounts(state) {
167
+ const counts = {
168
+ total: state.tasks.length,
169
+ ready: 0,
170
+ active: 0,
171
+ blocked: 0,
172
+ pending: 0,
173
+ completed: 0,
174
+ cancelled: 0,
175
+ };
176
+ for (const task of state.tasks) {
177
+ const availability = taskAvailability(task, state);
178
+ if (availability === 'ready')
179
+ counts.ready += 1;
180
+ if (availability === 'in_progress')
181
+ counts.active += 1;
182
+ if (availability === 'blocked' || availability === 'waiting')
183
+ counts.blocked += 1;
184
+ if (task.status === 'pending')
185
+ counts.pending += 1;
186
+ if (task.status === 'completed')
187
+ counts.completed += 1;
188
+ if (task.status === 'cancelled')
189
+ counts.cancelled += 1;
190
+ }
191
+ return counts;
192
+ }
193
+ export function hasOpenTasks(state) {
194
+ return state.tasks.some((task) => !isTerminal(task.status));
195
+ }
196
+ export function parseTaskState(value) {
197
+ if (!isRecord(value) || value.schemaVersion !== 1 || !isNonNegativeInteger(value.revision)) {
198
+ throw new TaskGraphError('invalid_state', 'Stored task state has an unsupported schema or revision');
199
+ }
200
+ if (!Array.isArray(value.tasks) || value.tasks.length > MAX_TASKS) {
201
+ throw new TaskGraphError('invalid_state', 'Stored task state has an invalid task collection');
202
+ }
203
+ const state = {
204
+ schemaVersion: 1,
205
+ revision: value.revision,
206
+ tasks: value.tasks.map(parseTask),
207
+ };
208
+ assertGraphValid(state.tasks);
209
+ for (const task of state.tasks) {
210
+ if (task.status === 'in_progress' || task.status === 'completed') {
211
+ assertDependenciesCompleted(task, state.tasks);
212
+ }
213
+ }
214
+ return state;
215
+ }
216
+ function transitionTask(state, task, input, actor, now) {
217
+ const status = input.status;
218
+ if (status === 'in_progress') {
219
+ if (task.status !== 'pending' && task.status !== 'in_progress') {
220
+ throw new TaskGraphError('not_ready', `${task.id} must be pending before it can be claimed`);
221
+ }
222
+ const active = state.tasks.find((entry) => (entry.status === 'in_progress'
223
+ && entry.ownerSessionId === actor
224
+ && entry.id !== task.id));
225
+ if (active)
226
+ throw new TaskGraphError('actor_busy', `${actor} already owns ${active.id}`);
227
+ assertDependenciesCompleted(task, state.tasks);
228
+ const base = clearTerminalFields(clearBlockedFields(task));
229
+ return {
230
+ ...base,
231
+ status: 'in_progress',
232
+ ownerSessionId: actor,
233
+ claimedAt: task.ownerSessionId === actor && task.claimedAt ? task.claimedAt : now,
234
+ };
235
+ }
236
+ if (status === 'pending') {
237
+ return { ...clearOwnership(clearTerminalFields(clearBlockedFields(task))), status: 'pending' };
238
+ }
239
+ if (status === 'blocked') {
240
+ const reason = input.blockedReason ?? task.blockedReason;
241
+ if (!reason)
242
+ throw new TaskGraphError('blocked_reason_required', 'Blocked tasks require a blocked reason');
243
+ return {
244
+ ...clearOwnership(clearTerminalFields(task)),
245
+ status: 'blocked',
246
+ blockedReason: requiredText(reason, 'blocked reason'),
247
+ };
248
+ }
249
+ if (status === 'completed') {
250
+ if (task.status !== 'in_progress' && task.status !== 'completed') {
251
+ throw new TaskGraphError('claim_required', `${task.id} must be claimed before completion`);
252
+ }
253
+ const result = input.result ?? task.result;
254
+ if (!result)
255
+ throw new TaskGraphError('result_required', 'Completed tasks require a result');
256
+ assertDependenciesCompleted(task, state.tasks);
257
+ return {
258
+ ...clearOwnership(clearBlockedFields(task)),
259
+ status: 'completed',
260
+ result: requiredText(result, 'result'),
261
+ completedAt: task.completedAt ?? now,
262
+ };
263
+ }
264
+ return { ...clearOwnership(clearTerminalFields(clearBlockedFields(task))), status: 'cancelled' };
265
+ }
266
+ function clearOwnership(task) {
267
+ const { ownerSessionId: _owner, claimedAt: _claimed, ...rest } = task;
268
+ return rest;
269
+ }
270
+ function clearBlockedFields(task) {
271
+ const { blockedReason: _reason, ...rest } = task;
272
+ return rest;
273
+ }
274
+ function clearTerminalFields(task) {
275
+ const { result: _result, completedAt: _completed, ...rest } = task;
276
+ return rest;
277
+ }
278
+ function assertDependenciesCompleted(task, tasks) {
279
+ const byId = new Map(tasks.map((entry) => [entry.id, entry]));
280
+ const incomplete = task.blockedBy.filter((id) => byId.get(id)?.status !== 'completed');
281
+ if (incomplete.length > 0) {
282
+ throw new TaskGraphError('blocked', `${task.id} is waiting on ${incomplete.join(', ')}`);
283
+ }
284
+ }
285
+ function assertGraphValid(tasks) {
286
+ const byId = new Map();
287
+ for (const task of tasks) {
288
+ if (byId.has(task.id))
289
+ throw new TaskGraphError('invalid_state', `Duplicate task id: ${task.id}`);
290
+ byId.set(task.id, task);
291
+ }
292
+ for (const task of tasks) {
293
+ if (task.blockedBy.length > MAX_DEPENDENCIES) {
294
+ throw new TaskGraphError('dependency_limit', `${task.id} has more than ${MAX_DEPENDENCIES} prerequisites`);
295
+ }
296
+ for (const blocker of task.blockedBy) {
297
+ if (blocker === task.id)
298
+ throw new TaskGraphError('self_dependency', `${task.id} cannot block itself`);
299
+ if (!byId.has(blocker))
300
+ throw new TaskGraphError('unknown_dependency', `Task not found: ${blocker}`);
301
+ }
302
+ }
303
+ const visiting = new Set();
304
+ const visited = new Set();
305
+ const path = [];
306
+ const visit = (id) => {
307
+ if (visited.has(id))
308
+ return;
309
+ if (visiting.has(id)) {
310
+ const start = path.indexOf(id);
311
+ throw new TaskGraphError('dependency_cycle', [...path.slice(start), id].join(' -> '));
312
+ }
313
+ visiting.add(id);
314
+ path.push(id);
315
+ for (const blocker of byId.get(id).blockedBy)
316
+ visit(blocker);
317
+ path.pop();
318
+ visiting.delete(id);
319
+ visited.add(id);
320
+ };
321
+ for (const task of tasks)
322
+ visit(task.id);
323
+ }
324
+ function matchesView(task, state, view) {
325
+ const availability = taskAvailability(task, state);
326
+ if (view === 'all')
327
+ return true;
328
+ if (view === 'current')
329
+ return !isTerminal(task.status);
330
+ if (view === 'ready')
331
+ return availability === 'ready';
332
+ if (view === 'active')
333
+ return task.status === 'in_progress';
334
+ if (view === 'blocked')
335
+ return availability === 'blocked' || availability === 'waiting';
336
+ if (view === 'pending')
337
+ return task.status === 'pending';
338
+ return task.status === 'completed';
339
+ }
340
+ function compareTasks(left, right, state) {
341
+ const rank = (task) => {
342
+ const availability = taskAvailability(task, state);
343
+ return availability === 'in_progress' ? 0
344
+ : availability === 'ready' ? 1
345
+ : availability === 'blocked' || availability === 'waiting' ? 2
346
+ : availability === 'completed' ? 3
347
+ : 4;
348
+ };
349
+ return rank(left) - rank(right)
350
+ || left.priority - right.priority
351
+ || left.createdAt.localeCompare(right.createdAt)
352
+ || left.id.localeCompare(right.id);
353
+ }
354
+ function parseTask(value) {
355
+ if (!isRecord(value) || typeof value.id !== 'string' || !TASK_ID_PATTERN.test(value.id)) {
356
+ throw new TaskGraphError('invalid_state', 'Stored task has an invalid id');
357
+ }
358
+ if (typeof value.title !== 'string' || !TASK_STATUSES.has(String(value.status))) {
359
+ throw new TaskGraphError('invalid_state', `Stored task ${value.id} has invalid required fields`);
360
+ }
361
+ if (!Array.isArray(value.blockedBy) || !value.blockedBy.every((id) => typeof id === 'string')) {
362
+ throw new TaskGraphError('invalid_state', `Stored task ${value.id} has invalid dependencies`);
363
+ }
364
+ const task = {
365
+ id: value.id,
366
+ title: requiredText(value.title, 'title'),
367
+ priority: priorityValue(value.priority),
368
+ status: value.status,
369
+ blockedBy: uniqueIds(value.blockedBy),
370
+ createdAt: storedText(value.createdAt, value.id, 'createdAt'),
371
+ updatedAt: storedText(value.updatedAt, value.id, 'updatedAt'),
372
+ ...optionalStoredText(value, 'description'),
373
+ ...optionalStoredText(value, 'acceptanceCriteria'),
374
+ ...optionalStoredText(value, 'ownerSessionId'),
375
+ ...optionalStoredText(value, 'claimedAt'),
376
+ ...optionalStoredText(value, 'notes'),
377
+ ...optionalStoredText(value, 'result'),
378
+ ...optionalStoredText(value, 'blockedReason'),
379
+ ...optionalStoredText(value, 'completedAt'),
380
+ };
381
+ if (task.status === 'in_progress' && (!task.ownerSessionId || !task.claimedAt)) {
382
+ throw new TaskGraphError('invalid_state', `Stored active task ${task.id} has no owner`);
383
+ }
384
+ if (task.status !== 'in_progress' && (task.ownerSessionId || task.claimedAt)) {
385
+ throw new TaskGraphError('invalid_state', `Stored inactive task ${task.id} has active ownership`);
386
+ }
387
+ if (task.status === 'blocked' && !task.blockedReason) {
388
+ throw new TaskGraphError('invalid_state', `Stored blocked task ${task.id} has no reason`);
389
+ }
390
+ if (task.status !== 'blocked' && task.blockedReason) {
391
+ throw new TaskGraphError('invalid_state', `Stored task ${task.id} has a stale blocked reason`);
392
+ }
393
+ if (task.status === 'completed' && (!task.result || !task.completedAt)) {
394
+ throw new TaskGraphError('invalid_state', `Stored completed task ${task.id} has no result`);
395
+ }
396
+ if (task.status !== 'completed' && (task.result || task.completedAt)) {
397
+ throw new TaskGraphError('invalid_state', `Stored task ${task.id} has stale completion data`);
398
+ }
399
+ return task;
400
+ }
401
+ function optionalStoredText(value, key) {
402
+ const entry = value[key];
403
+ if (entry === undefined)
404
+ return {};
405
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
406
+ throw new TaskGraphError('invalid_state', `Stored task has invalid ${String(key)}`);
407
+ }
408
+ return { [key]: entry };
409
+ }
410
+ function storedText(value, id, field) {
411
+ if (typeof value !== 'string' || value.trim().length === 0) {
412
+ throw new TaskGraphError('invalid_state', `Stored task ${id} has invalid ${field}`);
413
+ }
414
+ return value;
415
+ }
416
+ function requiredText(value, field) {
417
+ const text = value.trim();
418
+ if (!text)
419
+ throw new TaskGraphError('invalid_request', `${field} cannot be empty`);
420
+ return text;
421
+ }
422
+ function priorityValue(value) {
423
+ if (!Number.isInteger(value) || value < 0 || value > 4) {
424
+ throw new TaskGraphError('invalid_priority', 'Priority must be an integer from 0 to 4');
425
+ }
426
+ return value;
427
+ }
428
+ function uniqueIds(ids) {
429
+ const unique = [...new Set(ids.map((id) => id.trim()))];
430
+ if (unique.some((id) => !TASK_ID_PATTERN.test(id))) {
431
+ throw new TaskGraphError('invalid_id', 'Dependency IDs must use the task ID returned by TaskCreate');
432
+ }
433
+ if (unique.length > MAX_DEPENDENCIES) {
434
+ throw new TaskGraphError('dependency_limit', `A task may have at most ${MAX_DEPENDENCIES} prerequisites`);
435
+ }
436
+ return unique;
437
+ }
438
+ function isTerminal(status) {
439
+ return status === 'completed' || status === 'cancelled';
440
+ }
441
+ function isNonNegativeInteger(value) {
442
+ return Number.isInteger(value) && value >= 0;
443
+ }
444
+ function isRecord(value) {
445
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
446
+ }
447
+ //# sourceMappingURL=graph.js.map