@ilikexiaoni/pi-task-list 0.4.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,780 @@
1
+ export const TASK_LIST_VERSION = 2 as const;
2
+
3
+ export const TASK_STATUSES = [
4
+ "pending",
5
+ "in_progress",
6
+ "blocked",
7
+ "completed",
8
+ "cancelled",
9
+ ] as const;
10
+
11
+ export const TASK_PRIORITIES = ["high", "medium", "low"] as const;
12
+
13
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
14
+ export type TaskPriority = (typeof TASK_PRIORITIES)[number];
15
+ export type TaskBlockSource = "" | "dependency" | "manual";
16
+ export type TaskAction = "create" | "update" | "list" | "get" | "clear";
17
+ export type ClearScope = "finished" | "all";
18
+
19
+ export interface TaskNote {
20
+ at: string;
21
+ text: string;
22
+ }
23
+
24
+ export interface Task {
25
+ id: number;
26
+ subject: string;
27
+ description: string;
28
+ activeForm: string;
29
+ acceptanceCriteria: string[];
30
+ evidence: string[];
31
+ status: TaskStatus;
32
+ priority: TaskPriority;
33
+ blockedBy: number[];
34
+ blockReason: string;
35
+ blockSource: TaskBlockSource;
36
+ tags: string[];
37
+ owner: string;
38
+ completionNote: string;
39
+ notes: TaskNote[];
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ }
43
+
44
+ export interface TaskState {
45
+ version: typeof TASK_LIST_VERSION;
46
+ revision: number;
47
+ nextId: number;
48
+ updatedAt: string;
49
+ tasks: Task[];
50
+ }
51
+
52
+ export interface TaskCommand {
53
+ action: TaskAction;
54
+ id?: number;
55
+ subject?: string;
56
+ description?: string;
57
+ activeForm?: string;
58
+ acceptanceCriteria?: string[];
59
+ evidence?: string[];
60
+ addEvidence?: string[];
61
+ status?: TaskStatus;
62
+ priority?: TaskPriority;
63
+ blockedBy?: number[];
64
+ addBlockedBy?: number[];
65
+ removeBlockedBy?: number[];
66
+ blockReason?: string;
67
+ tags?: string[];
68
+ addTags?: string[];
69
+ removeTags?: string[];
70
+ owner?: string;
71
+ completionNote?: string;
72
+ note?: string;
73
+ query?: string;
74
+ includeCancelled?: boolean;
75
+ scope?: ClearScope;
76
+ confirm?: boolean;
77
+ }
78
+
79
+ export interface ApplyResult {
80
+ action: TaskAction;
81
+ state: TaskState;
82
+ changedIds: number[];
83
+ visibleTasks: Task[];
84
+ selectedTask?: Task;
85
+ message: string;
86
+ mutated: boolean;
87
+ }
88
+
89
+ export class TaskListError extends Error {
90
+ readonly code: string;
91
+
92
+ constructor(code: string, message: string) {
93
+ super(message);
94
+ this.name = "TaskListError";
95
+ this.code = code;
96
+ }
97
+ }
98
+
99
+ const MAX_SUBJECT_LENGTH = 300;
100
+ const MAX_FIELD_LENGTH = 4000;
101
+ const MAX_LIST_ITEMS = 50;
102
+ const MAX_NOTES = 100;
103
+
104
+ function isRecord(value: unknown): value is Record<string, unknown> {
105
+ return typeof value === "object" && value !== null;
106
+ }
107
+
108
+ function isTaskStatus(value: unknown): value is TaskStatus {
109
+ return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
110
+ }
111
+
112
+ function isTaskPriority(value: unknown): value is TaskPriority {
113
+ return typeof value === "string" && (TASK_PRIORITIES as readonly string[]).includes(value);
114
+ }
115
+
116
+ function isTaskBlockSource(value: unknown): value is TaskBlockSource {
117
+ return value === "" || value === "dependency" || value === "manual";
118
+ }
119
+
120
+ function positiveInteger(value: unknown): number | undefined {
121
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
122
+ }
123
+
124
+ function boundedText(value: unknown, field: string, max = MAX_FIELD_LENGTH): string {
125
+ if (typeof value !== "string") {
126
+ throw new TaskListError("invalid_argument", `${field} 必须是字符串`);
127
+ }
128
+ const text = value.trim();
129
+ if (text.length > max) {
130
+ throw new TaskListError("invalid_argument", `${field} 不能超过 ${max} 个字符`);
131
+ }
132
+ return text;
133
+ }
134
+
135
+ function requiredText(value: unknown, field: string, max = MAX_FIELD_LENGTH): string {
136
+ const text = boundedText(value, field, max);
137
+ if (!text) {
138
+ throw new TaskListError("invalid_argument", `${field} 不能为空`);
139
+ }
140
+ return text;
141
+ }
142
+
143
+ function stringList(value: unknown, field: string, maxItems = MAX_LIST_ITEMS): string[] {
144
+ if (!Array.isArray(value)) {
145
+ throw new TaskListError("invalid_argument", `${field} 必须是字符串数组`);
146
+ }
147
+ if (value.length > maxItems) {
148
+ throw new TaskListError("invalid_argument", `${field} 最多包含 ${maxItems} 项`);
149
+ }
150
+ const result: string[] = [];
151
+ for (const item of value) {
152
+ const text = requiredText(item, `${field} 项`, MAX_FIELD_LENGTH);
153
+ if (!result.includes(text)) result.push(text);
154
+ }
155
+ return result;
156
+ }
157
+
158
+ function idList(value: unknown, field: string): number[] {
159
+ if (!Array.isArray(value)) {
160
+ throw new TaskListError("invalid_argument", `${field} 必须是数字数组`);
161
+ }
162
+ if (value.length > MAX_LIST_ITEMS) {
163
+ throw new TaskListError("invalid_argument", `${field} 最多包含 ${MAX_LIST_ITEMS} 项`);
164
+ }
165
+ const result: number[] = [];
166
+ for (const item of value) {
167
+ const id = positiveInteger(item);
168
+ if (id === undefined) {
169
+ throw new TaskListError("invalid_argument", `${field} 只能包含正整数任务 ID`);
170
+ }
171
+ if (!result.includes(id)) result.push(id);
172
+ }
173
+ return result;
174
+ }
175
+
176
+ function safeStringList(value: unknown): string[] {
177
+ if (!Array.isArray(value)) return [];
178
+ return value
179
+ .filter((item): item is string => typeof item === "string")
180
+ .map((item) => item.trim().slice(0, MAX_FIELD_LENGTH))
181
+ .filter(Boolean)
182
+ .filter((item, index, list) => list.indexOf(item) === index)
183
+ .slice(0, MAX_LIST_ITEMS);
184
+ }
185
+
186
+ function safeIdList(value: unknown): number[] {
187
+ if (!Array.isArray(value)) return [];
188
+ return value
189
+ .map(positiveInteger)
190
+ .filter((item): item is number => item !== undefined)
191
+ .filter((item, index, list) => list.indexOf(item) === index)
192
+ .slice(0, MAX_LIST_ITEMS);
193
+ }
194
+
195
+ function safeNotes(value: unknown): TaskNote[] {
196
+ if (!Array.isArray(value)) return [];
197
+ return value
198
+ .filter(isRecord)
199
+ .map((item) => ({
200
+ at: typeof item.at === "string" ? item.at : "",
201
+ text: typeof item.text === "string" ? item.text.trim().slice(0, MAX_FIELD_LENGTH) : "",
202
+ }))
203
+ .filter((item) => item.text.length > 0)
204
+ .slice(-MAX_NOTES);
205
+ }
206
+
207
+ function cloneTask(task: Task): Task {
208
+ return {
209
+ ...task,
210
+ acceptanceCriteria: [...task.acceptanceCriteria],
211
+ evidence: [...task.evidence],
212
+ blockedBy: [...task.blockedBy],
213
+ tags: [...task.tags],
214
+ notes: task.notes.map((note) => ({ ...note })),
215
+ };
216
+ }
217
+
218
+ export function cloneState(state: TaskState): TaskState {
219
+ return {
220
+ ...state,
221
+ tasks: state.tasks.map(cloneTask),
222
+ };
223
+ }
224
+
225
+ export function createEmptyState(now = new Date().toISOString()): TaskState {
226
+ return {
227
+ version: TASK_LIST_VERSION,
228
+ revision: 0,
229
+ nextId: 1,
230
+ updatedAt: now,
231
+ tasks: [],
232
+ };
233
+ }
234
+
235
+ /** Normalize persisted data defensively so a malformed old session cannot break startup. */
236
+ export function normalizeState(value: unknown): TaskState {
237
+ if (!isRecord(value)) return createEmptyState();
238
+
239
+ const rawTasks = Array.isArray(value.tasks) ? value.tasks : [];
240
+ const tasks: Task[] = [];
241
+ const seenIds = new Set<number>();
242
+ let fallbackId = 1;
243
+
244
+ for (const rawTask of rawTasks) {
245
+ if (!isRecord(rawTask)) continue;
246
+ const rawId = positiveInteger(rawTask.id);
247
+ const id = rawId ?? fallbackId;
248
+ fallbackId = Math.max(fallbackId, id + 1);
249
+ if (seenIds.has(id)) continue;
250
+ seenIds.add(id);
251
+
252
+ const subject =
253
+ typeof rawTask.subject === "string" && rawTask.subject.trim()
254
+ ? rawTask.subject.trim().slice(0, MAX_SUBJECT_LENGTH)
255
+ : `Task #${id}`;
256
+ const status = isTaskStatus(rawTask.status) ? rawTask.status : "pending";
257
+ const priority = isTaskPriority(rawTask.priority) ? rawTask.priority : "medium";
258
+ const description = typeof rawTask.description === "string" ? rawTask.description.trim().slice(0, MAX_FIELD_LENGTH) : "";
259
+ const activeForm =
260
+ typeof rawTask.activeForm === "string" && rawTask.activeForm.trim()
261
+ ? rawTask.activeForm.trim().slice(0, MAX_SUBJECT_LENGTH)
262
+ : subject;
263
+ const blockedBy = safeIdList(rawTask.blockedBy);
264
+ const blockReason = typeof rawTask.blockReason === "string" ? rawTask.blockReason.trim().slice(0, MAX_FIELD_LENGTH) : "";
265
+ const inferredBlockSource: TaskBlockSource =
266
+ status !== "blocked"
267
+ ? ""
268
+ : blockedBy.length > 0 && (!blockReason || blockReason.startsWith("等待任务 "))
269
+ ? "dependency"
270
+ : "manual";
271
+ const blockSource = isTaskBlockSource(rawTask.blockSource) ? rawTask.blockSource : inferredBlockSource;
272
+
273
+ tasks.push({
274
+ id,
275
+ subject,
276
+ description,
277
+ activeForm,
278
+ acceptanceCriteria: safeStringList(rawTask.acceptanceCriteria),
279
+ evidence: safeStringList(rawTask.evidence),
280
+ status,
281
+ priority,
282
+ blockedBy,
283
+ blockReason,
284
+ blockSource: status === "blocked" ? blockSource : "",
285
+ tags: safeStringList(rawTask.tags),
286
+ owner: typeof rawTask.owner === "string" ? rawTask.owner.trim().slice(0, 200) : "",
287
+ completionNote:
288
+ typeof rawTask.completionNote === "string" ? rawTask.completionNote.trim().slice(0, MAX_FIELD_LENGTH) : "",
289
+ notes: safeNotes(rawTask.notes),
290
+ createdAt: typeof rawTask.createdAt === "string" ? rawTask.createdAt : "",
291
+ updatedAt: typeof rawTask.updatedAt === "string" ? rawTask.updatedAt : "",
292
+ });
293
+ }
294
+
295
+ const rawNextId = positiveInteger(value.nextId) ?? 1;
296
+ const rawRevision = typeof value.revision === "number" && Number.isInteger(value.revision) && value.revision >= 0 ? value.revision : 0;
297
+ const updatedAt = typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString();
298
+
299
+ const state: TaskState = {
300
+ version: TASK_LIST_VERSION,
301
+ revision: rawRevision,
302
+ nextId: Math.max(rawNextId, fallbackId, ...tasks.map((task) => task.id + 1)),
303
+ updatedAt,
304
+ tasks,
305
+ };
306
+ reconcileDependencyBlocks(state, updatedAt, false);
307
+ return state;
308
+ }
309
+
310
+ function taskById(state: TaskState, id: number | undefined): Task {
311
+ const validId = positiveInteger(id);
312
+ if (validId === undefined) {
313
+ throw new TaskListError("invalid_argument", "必须提供正整数任务 ID");
314
+ }
315
+ const task = state.tasks.find((item) => item.id === validId);
316
+ if (!task) {
317
+ throw new TaskListError("not_found", `任务 #${validId} 不存在`);
318
+ }
319
+ return task;
320
+ }
321
+
322
+ function validateReferences(state: TaskState, ids: number[], selfId?: number): void {
323
+ const known = new Set(state.tasks.map((task) => task.id));
324
+ for (const id of ids) {
325
+ if (selfId === id) {
326
+ throw new TaskListError("dependency_cycle", `任务 #${id} 不能依赖自己`);
327
+ }
328
+ if (!known.has(id)) {
329
+ throw new TaskListError("not_found", `依赖任务 #${id} 不存在`);
330
+ }
331
+ }
332
+ }
333
+
334
+ function validateNoCycle(state: TaskState, taskId: number, dependencies: number[]): void {
335
+ const graph = new Map(state.tasks.map((task) => [task.id, task.blockedBy]));
336
+ graph.set(taskId, dependencies);
337
+
338
+ const reachesTask = (current: number, visited: Set<number>): boolean => {
339
+ if (current === taskId) return true;
340
+ if (visited.has(current)) return false;
341
+ visited.add(current);
342
+ for (const dependency of graph.get(current) ?? []) {
343
+ if (reachesTask(dependency, new Set(visited))) return true;
344
+ }
345
+ return false;
346
+ };
347
+
348
+ for (const dependency of dependencies) {
349
+ if (reachesTask(dependency, new Set())) {
350
+ throw new TaskListError("dependency_cycle", `任务 #${taskId} 的依赖会形成循环`);
351
+ }
352
+ }
353
+ }
354
+
355
+ function unresolvedDependencies(state: TaskState, task: Task): Task[] {
356
+ const resolved = new Set(["completed", "cancelled"]);
357
+ return task.blockedBy
358
+ .map((id) => state.tasks.find((candidate) => candidate.id === id))
359
+ .filter((candidate): candidate is Task => candidate !== undefined && !resolved.has(candidate.status));
360
+ }
361
+
362
+ function dependencyReason(tasks: Task[]): string {
363
+ return `等待任务 ${tasks.map((task) => `#${task.id}`).join(", ")} 完成`;
364
+ }
365
+
366
+ function normalizeStatus(value: unknown): TaskStatus {
367
+ if (!isTaskStatus(value)) {
368
+ throw new TaskListError("invalid_argument", `status 必须是 ${TASK_STATUSES.join(", ")} 之一`);
369
+ }
370
+ return value;
371
+ }
372
+
373
+ function normalizePriority(value: unknown): TaskPriority {
374
+ if (!isTaskPriority(value)) {
375
+ throw new TaskListError("invalid_argument", `priority 必须是 ${TASK_PRIORITIES.join(", ")} 之一`);
376
+ }
377
+ return value;
378
+ }
379
+
380
+ function appendNote(task: Task, text: string, at: string): void {
381
+ task.notes.push({ at, text });
382
+ if (task.notes.length > MAX_NOTES) task.notes = task.notes.slice(-MAX_NOTES);
383
+ }
384
+
385
+ function touch(state: TaskState, now: string): void {
386
+ state.revision += 1;
387
+ state.updatedAt = now;
388
+ }
389
+
390
+ function makeTask(state: TaskState, params: TaskCommand, now: string): Task {
391
+ const subject = requiredText(params.subject, "subject", MAX_SUBJECT_LENGTH);
392
+ const requestedStatus = params.status === undefined ? "pending" : normalizeStatus(params.status);
393
+ const priority = params.priority === undefined ? "medium" : normalizePriority(params.priority);
394
+ const blockedBy = params.blockedBy === undefined ? [] : idList(params.blockedBy, "blockedBy");
395
+ validateReferences(state, blockedBy);
396
+
397
+ if (requestedStatus === "completed" || requestedStatus === "cancelled") {
398
+ throw new TaskListError("invalid_transition", "新任务只能从 pending、in_progress 或 blocked 开始");
399
+ }
400
+
401
+ const task: Task = {
402
+ id: state.nextId++,
403
+ subject,
404
+ description: params.description === undefined ? "" : boundedText(params.description, "description"),
405
+ activeForm:
406
+ params.activeForm === undefined || !params.activeForm.trim()
407
+ ? subject
408
+ : boundedText(params.activeForm, "activeForm", MAX_SUBJECT_LENGTH),
409
+ acceptanceCriteria: params.acceptanceCriteria === undefined ? [] : stringList(params.acceptanceCriteria, "acceptanceCriteria"),
410
+ evidence: params.evidence === undefined ? [] : stringList(params.evidence, "evidence"),
411
+ status: requestedStatus,
412
+ priority,
413
+ blockedBy,
414
+ blockReason: params.blockReason === undefined ? "" : boundedText(params.blockReason, "blockReason"),
415
+ blockSource: "",
416
+ tags: params.tags === undefined ? [] : stringList(params.tags, "tags"),
417
+ owner: params.owner === undefined ? "" : boundedText(params.owner, "owner", 200),
418
+ completionNote: params.completionNote === undefined ? "" : boundedText(params.completionNote, "completionNote"),
419
+ notes: [],
420
+ createdAt: now,
421
+ updatedAt: now,
422
+ };
423
+
424
+ const unresolved = unresolvedDependencies(state, task);
425
+ if (unresolved.length > 0) {
426
+ if (requestedStatus === "in_progress") {
427
+ throw new TaskListError("blocked_dependency", `${dependencyReason(unresolved)},不能直接开始任务 #${task.id}`);
428
+ }
429
+ task.status = "blocked";
430
+ if (task.blockReason) {
431
+ task.blockSource = "manual";
432
+ } else {
433
+ task.blockReason = dependencyReason(unresolved);
434
+ task.blockSource = "dependency";
435
+ }
436
+ appendNote(task, `自动标记为 blocked:${task.blockReason}`, now);
437
+ } else if (task.status === "blocked") {
438
+ if (!task.blockReason) {
439
+ throw new TaskListError("invalid_argument", "blocked 状态必须提供 blockReason 或未完成的 blockedBy");
440
+ }
441
+ task.blockSource = "manual";
442
+ } else {
443
+ task.blockReason = "";
444
+ task.blockSource = "";
445
+ }
446
+
447
+ if (params.note !== undefined) {
448
+ appendNote(task, requiredText(params.note, "note"), now);
449
+ }
450
+
451
+ return task;
452
+ }
453
+
454
+ function updateTask(state: TaskState, params: TaskCommand, now: string): { task: Task; changed: boolean } {
455
+ const current = taskById(state, params.id);
456
+ const before = cloneTask(current);
457
+ const candidate = cloneTask(current);
458
+
459
+ if (params.subject !== undefined) candidate.subject = requiredText(params.subject, "subject", MAX_SUBJECT_LENGTH);
460
+ if (params.description !== undefined) candidate.description = boundedText(params.description, "description");
461
+ if (params.activeForm !== undefined) {
462
+ const activeForm = boundedText(params.activeForm, "activeForm", MAX_SUBJECT_LENGTH);
463
+ candidate.activeForm = activeForm || candidate.subject;
464
+ }
465
+ if (params.acceptanceCriteria !== undefined) {
466
+ candidate.acceptanceCriteria = stringList(params.acceptanceCriteria, "acceptanceCriteria");
467
+ }
468
+ if (params.evidence !== undefined) candidate.evidence = stringList(params.evidence, "evidence");
469
+ if (params.addEvidence !== undefined) {
470
+ for (const item of stringList(params.addEvidence, "addEvidence")) {
471
+ if (!candidate.evidence.includes(item)) candidate.evidence.push(item);
472
+ }
473
+ }
474
+ if (params.priority !== undefined) candidate.priority = normalizePriority(params.priority);
475
+ if (params.blockedBy !== undefined) candidate.blockedBy = idList(params.blockedBy, "blockedBy");
476
+ if (params.addBlockedBy !== undefined) {
477
+ for (const id of idList(params.addBlockedBy, "addBlockedBy")) {
478
+ if (!candidate.blockedBy.includes(id)) candidate.blockedBy.push(id);
479
+ }
480
+ }
481
+ if (params.removeBlockedBy !== undefined) {
482
+ const remove = new Set(idList(params.removeBlockedBy, "removeBlockedBy"));
483
+ candidate.blockedBy = candidate.blockedBy.filter((id) => !remove.has(id));
484
+ }
485
+ if (params.blockReason !== undefined) {
486
+ candidate.blockReason = boundedText(params.blockReason, "blockReason");
487
+ candidate.blockSource = candidate.blockReason ? "manual" : "";
488
+ }
489
+ if (params.tags !== undefined) candidate.tags = stringList(params.tags, "tags");
490
+ if (params.addTags !== undefined) {
491
+ for (const tag of stringList(params.addTags, "addTags")) {
492
+ if (!candidate.tags.includes(tag)) candidate.tags.push(tag);
493
+ }
494
+ }
495
+ if (params.removeTags !== undefined) {
496
+ const remove = new Set(stringList(params.removeTags, "removeTags"));
497
+ candidate.tags = candidate.tags.filter((tag) => !remove.has(tag));
498
+ }
499
+ if (params.owner !== undefined) candidate.owner = boundedText(params.owner, "owner", 200);
500
+ if (params.completionNote !== undefined) candidate.completionNote = boundedText(params.completionNote, "completionNote");
501
+
502
+ if (params.status !== undefined) {
503
+ candidate.status = normalizeStatus(params.status);
504
+ if (candidate.status !== "blocked") {
505
+ candidate.blockReason = "";
506
+ candidate.blockSource = "";
507
+ }
508
+ }
509
+
510
+ validateReferences(state, candidate.blockedBy, candidate.id);
511
+ validateNoCycle(state, candidate.id, candidate.blockedBy);
512
+
513
+ const unresolved = unresolvedDependencies(state, candidate);
514
+ if (candidate.status === "in_progress" && unresolved.length > 0) {
515
+ throw new TaskListError("blocked_dependency", `${dependencyReason(unresolved)},不能将任务 #${candidate.id} 标记为 in_progress`);
516
+ }
517
+ if (candidate.status === "completed") {
518
+ if (unresolved.length > 0) {
519
+ throw new TaskListError("blocked_dependency", `${dependencyReason(unresolved)},不能完成任务 #${candidate.id}`);
520
+ }
521
+ if (candidate.evidence.length < candidate.acceptanceCriteria.length) {
522
+ throw new TaskListError(
523
+ "evidence_required",
524
+ `任务 #${candidate.id} 有 ${candidate.acceptanceCriteria.length} 条完成条件,标记 completed 前至少需要同等数量的 evidence(当前 ${candidate.evidence.length} 条)`,
525
+ );
526
+ }
527
+ }
528
+ if (unresolved.length > 0 && candidate.status === "pending") {
529
+ candidate.status = "blocked";
530
+ candidate.blockReason = dependencyReason(unresolved);
531
+ candidate.blockSource = "dependency";
532
+ }
533
+ if (candidate.status === "blocked") {
534
+ if (candidate.blockSource === "manual" && candidate.blockReason) {
535
+ // Manual blockers remain until explicitly cleared by the caller.
536
+ } else if (unresolved.length > 0) {
537
+ candidate.blockReason = dependencyReason(unresolved);
538
+ candidate.blockSource = "dependency";
539
+ } else if (params.status === "blocked") {
540
+ throw new TaskListError("invalid_argument", "blocked 状态必须提供 blockReason 或未完成的 blockedBy");
541
+ } else {
542
+ candidate.status = "pending";
543
+ candidate.blockReason = "";
544
+ candidate.blockSource = "";
545
+ appendNote(candidate, "依赖已完成,自动恢复为 pending", now);
546
+ }
547
+ }
548
+ if (candidate.status !== "blocked") {
549
+ candidate.blockReason = "";
550
+ candidate.blockSource = "";
551
+ }
552
+
553
+ if (params.note !== undefined) appendNote(candidate, requiredText(params.note, "note"), now);
554
+ if (candidate.status !== before.status) {
555
+ appendNote(candidate, `状态变更:${before.status} -> ${candidate.status}`, now);
556
+ }
557
+
558
+ const changed = JSON.stringify(candidate) !== JSON.stringify(before);
559
+ if (changed) candidate.updatedAt = now;
560
+ return { task: candidate, changed };
561
+ }
562
+
563
+ function reconcileDependencyBlocks(state: TaskState, now: string, recordNotes = true): number[] {
564
+ const changedIds: number[] = [];
565
+
566
+ for (const task of state.tasks) {
567
+ if (task.status === "completed" || task.status === "cancelled") continue;
568
+ const before = cloneTask(task);
569
+ const unresolved = unresolvedDependencies(state, task);
570
+
571
+ if (unresolved.length > 0) {
572
+ if (task.status === "pending" || task.status === "in_progress" || task.blockSource === "dependency") {
573
+ task.status = "blocked";
574
+ task.blockReason = dependencyReason(unresolved);
575
+ task.blockSource = "dependency";
576
+ }
577
+ } else if (task.status === "blocked" && task.blockSource === "dependency") {
578
+ task.status = "pending";
579
+ task.blockReason = "";
580
+ task.blockSource = "";
581
+ }
582
+
583
+ if (JSON.stringify(task) === JSON.stringify(before)) continue;
584
+ if (recordNotes && task.status !== before.status) {
585
+ const note = task.status === "blocked" ? `依赖未完成,自动标记为 blocked:${task.blockReason}` : "依赖已完成,自动恢复为 pending";
586
+ appendNote(task, note, now);
587
+ }
588
+ task.updatedAt = now;
589
+ changedIds.push(task.id);
590
+ }
591
+
592
+ return changedIds;
593
+ }
594
+
595
+ export function filterTasks(state: TaskState, params: Pick<TaskCommand, "status" | "priority" | "query" | "includeCancelled"> = {}): Task[] {
596
+ const status = params.status === undefined ? undefined : normalizeStatus(params.status);
597
+ const priority = params.priority === undefined ? undefined : normalizePriority(params.priority);
598
+ const query = params.query?.trim().toLowerCase();
599
+
600
+ return state.tasks
601
+ .filter((task) => params.includeCancelled === true || task.status !== "cancelled")
602
+ .filter((task) => status === undefined || task.status === status)
603
+ .filter((task) => priority === undefined || task.priority === priority)
604
+ .filter((task) => {
605
+ if (!query) return true;
606
+ return [task.subject, task.description, task.owner, ...task.tags].some((value) => value.toLowerCase().includes(query));
607
+ })
608
+ .sort(compareTasks)
609
+ .map(cloneTask);
610
+ }
611
+
612
+ function compareTasks(left: Task, right: Task): number {
613
+ const statusRank: Record<TaskStatus, number> = {
614
+ in_progress: 0,
615
+ blocked: 1,
616
+ pending: 2,
617
+ completed: 3,
618
+ cancelled: 4,
619
+ };
620
+ const priorityRank: Record<TaskPriority, number> = { high: 0, medium: 1, low: 2 };
621
+ return statusRank[left.status] - statusRank[right.status] || priorityRank[left.priority] - priorityRank[right.priority] || left.id - right.id;
622
+ }
623
+
624
+ export function isTaskReady(state: TaskState, task: Task): boolean {
625
+ return task.status === "pending" && unresolvedDependencies(state, task).length === 0;
626
+ }
627
+
628
+ export function applyTaskAction(current: TaskState, params: TaskCommand, now = new Date().toISOString()): ApplyResult {
629
+ const state = cloneState(normalizeState(current));
630
+
631
+ switch (params.action) {
632
+ case "create": {
633
+ const task = makeTask(state, params, now);
634
+ state.tasks.push(task);
635
+ touch(state, now);
636
+ return {
637
+ action: "create",
638
+ state,
639
+ changedIds: [task.id],
640
+ visibleTasks: [cloneTask(task)],
641
+ selectedTask: cloneTask(task),
642
+ message: `已创建任务 #${task.id}:${task.subject}${task.status === "blocked" ? `(blocked:${task.blockReason})` : ""}`,
643
+ mutated: true,
644
+ };
645
+ }
646
+
647
+ case "update": {
648
+ const result = updateTask(state, params, now);
649
+ if (!result.changed) {
650
+ return {
651
+ action: "update",
652
+ state,
653
+ changedIds: [],
654
+ visibleTasks: [cloneTask(result.task)],
655
+ selectedTask: cloneTask(result.task),
656
+ message: `任务 #${result.task.id} 未发生变化`,
657
+ mutated: false,
658
+ };
659
+ }
660
+ const index = state.tasks.findIndex((task) => task.id === result.task.id);
661
+ state.tasks[index] = result.task;
662
+ const dependentChanges = reconcileDependencyBlocks(state, now).filter((id) => id !== result.task.id);
663
+ const changedIds = [result.task.id, ...dependentChanges];
664
+ const selectedTask = cloneTask(state.tasks[index]);
665
+ touch(state, now);
666
+ return {
667
+ action: "update",
668
+ state,
669
+ changedIds,
670
+ visibleTasks: state.tasks.filter((task) => changedIds.includes(task.id)).map(cloneTask),
671
+ selectedTask,
672
+ message: `已更新任务 #${selectedTask.id}:${selectedTask.subject} [${selectedTask.status}]${dependentChanges.length > 0 ? `;同步更新 ${dependentChanges.length} 个依赖任务` : ""}`,
673
+ mutated: true,
674
+ };
675
+ }
676
+
677
+ case "list": {
678
+ const visibleTasks = filterTasks(state, params);
679
+ return {
680
+ action: "list",
681
+ state,
682
+ changedIds: [],
683
+ visibleTasks,
684
+ message: `当前显示 ${visibleTasks.length} 个任务`,
685
+ mutated: false,
686
+ };
687
+ }
688
+
689
+ case "get": {
690
+ const task = cloneTask(taskById(state, params.id));
691
+ return {
692
+ action: "get",
693
+ state,
694
+ changedIds: [],
695
+ visibleTasks: [task],
696
+ selectedTask: task,
697
+ message: `任务 #${task.id}:${task.subject}`,
698
+ mutated: false,
699
+ };
700
+ }
701
+
702
+ case "clear": {
703
+ if (params.confirm !== true) {
704
+ throw new TaskListError("confirmation_required", "clear 是破坏性操作,必须显式提供 confirm=true");
705
+ }
706
+ const scope = params.scope ?? "finished";
707
+ if (scope !== "finished" && scope !== "all") {
708
+ throw new TaskListError("invalid_argument", "scope 必须是 finished 或 all");
709
+ }
710
+ const removed = scope === "all" ? [...state.tasks] : state.tasks.filter((task) => task.status === "completed" || task.status === "cancelled");
711
+ if (removed.length === 0) {
712
+ return {
713
+ action: "clear",
714
+ state,
715
+ changedIds: [],
716
+ visibleTasks: [],
717
+ message: "没有符合清理范围的任务",
718
+ mutated: false,
719
+ };
720
+ }
721
+ const removedIds = new Set(removed.map((task) => task.id));
722
+ state.tasks = scope === "all" ? [] : state.tasks.filter((task) => !removedIds.has(task.id));
723
+ for (const task of state.tasks) {
724
+ task.blockedBy = task.blockedBy.filter((id) => !removedIds.has(id));
725
+ }
726
+ touch(state, now);
727
+ return {
728
+ action: "clear",
729
+ state,
730
+ changedIds: removed.map((task) => task.id),
731
+ visibleTasks: state.tasks.map(cloneTask),
732
+ message: `已清理 ${removed.length} 个任务(范围:${scope})`,
733
+ mutated: true,
734
+ };
735
+ }
736
+
737
+ default:
738
+ throw new TaskListError("invalid_argument", `不支持的 action:${String(params.action)}`);
739
+ }
740
+ }
741
+
742
+ export function statusLabel(status: TaskStatus): string {
743
+ return {
744
+ pending: "待处理",
745
+ in_progress: "进行中",
746
+ blocked: "阻塞",
747
+ completed: "已完成",
748
+ cancelled: "已取消",
749
+ }[status];
750
+ }
751
+
752
+ export function priorityLabel(priority: TaskPriority): string {
753
+ return { high: "高", medium: "中", low: "低" }[priority];
754
+ }
755
+
756
+ export function summarizeState(state: TaskState): { total: number; active: number; blocked: number; pending: number; completed: number; cancelled: number; ready: number } {
757
+ const summary = { total: 0, active: 0, blocked: 0, pending: 0, completed: 0, cancelled: 0, ready: 0 };
758
+ const statusById = new Map(state.tasks.map((task) => [task.id, task.status]));
759
+
760
+ for (const task of state.tasks) {
761
+ if (task.status === "cancelled") {
762
+ summary.cancelled++;
763
+ continue;
764
+ }
765
+ summary.total++;
766
+ if (task.status === "in_progress") summary.active++;
767
+ if (task.status === "blocked") summary.blocked++;
768
+ if (task.status === "completed") summary.completed++;
769
+ if (task.status === "pending") {
770
+ summary.pending++;
771
+ const dependenciesResolved = task.blockedBy.every((id) => {
772
+ const status = statusById.get(id);
773
+ return status === undefined || status === "completed" || status === "cancelled";
774
+ });
775
+ if (dependenciesResolved) summary.ready++;
776
+ }
777
+ }
778
+
779
+ return summary;
780
+ }