@ilikexiaoni/pi-task-list 0.4.0 → 0.4.2

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.
@@ -49,6 +49,16 @@ export interface TaskState {
49
49
  tasks: Task[];
50
50
  }
51
51
 
52
+ export interface TaskSummary {
53
+ total: number;
54
+ active: number;
55
+ blocked: number;
56
+ pending: number;
57
+ completed: number;
58
+ cancelled: number;
59
+ ready: number;
60
+ }
61
+
52
62
  export interface TaskCommand {
53
63
  action: TaskAction;
54
64
  id?: number;
@@ -76,6 +86,13 @@ export interface TaskCommand {
76
86
  confirm?: boolean;
77
87
  }
78
88
 
89
+ /** A mutation record that can be replayed after compaction or session restore. */
90
+ export interface TaskOperation {
91
+ version: 1;
92
+ command: TaskCommand;
93
+ at: string;
94
+ }
95
+
79
96
  export interface ApplyResult {
80
97
  action: TaskAction;
81
98
  state: TaskState;
@@ -96,15 +113,45 @@ export class TaskListError extends Error {
96
113
  }
97
114
  }
98
115
 
99
- const MAX_SUBJECT_LENGTH = 300;
100
- const MAX_FIELD_LENGTH = 4000;
101
- const MAX_LIST_ITEMS = 50;
102
- const MAX_NOTES = 100;
116
+ export const MAX_TASKS = 200;
117
+ export const MAX_TASK_ID = Number.MAX_SAFE_INTEGER;
118
+ export const MAX_SUBJECT_LENGTH = 300;
119
+ export const MAX_FIELD_LENGTH = 4000;
120
+ export const MAX_LIST_ITEMS = 50;
121
+ export const MAX_NOTES = 100;
103
122
 
104
123
  function isRecord(value: unknown): value is Record<string, unknown> {
105
124
  return typeof value === "object" && value !== null;
106
125
  }
107
126
 
127
+ /** Identify a persisted task snapshot without silently treating malformed data as an empty list. */
128
+ export function isPersistedTaskState(value: unknown): value is { tasks: unknown[] } {
129
+ if (!isRecord(value) || !Array.isArray(value.tasks)) return false;
130
+ const seenIds = new Set<number>();
131
+ return value.tasks.every((task) => {
132
+ if (!isRecord(task)) return false;
133
+ const subject = typeof task.subject === "string" ? sanitizeTaskText(task.subject) : "";
134
+ if (!subject) return false;
135
+
136
+ const hasPersistedId = task.id !== undefined;
137
+ const id = positiveInteger(task.id);
138
+ // Missing IDs can be migrated by normalizeState; malformed explicit IDs cannot.
139
+ if (hasPersistedId && id === undefined) return false;
140
+ if (id !== undefined) {
141
+ if (seenIds.has(id)) return false;
142
+ seenIds.add(id);
143
+ }
144
+ return true;
145
+ });
146
+ }
147
+
148
+ export function isPersistedTaskOperation(value: unknown): value is TaskOperation {
149
+ if (!isRecord(value) || (value.version !== undefined && value.version !== 1)) return false;
150
+ if (typeof value.at !== "string" || !value.at.trim() || value.at.length > 64 || !isRecord(value.command)) return false;
151
+ const action = value.command.action;
152
+ return typeof action === "string" && ["create", "update", "clear"].includes(action);
153
+ }
154
+
108
155
  function isTaskStatus(value: unknown): value is TaskStatus {
109
156
  return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
110
157
  }
@@ -118,14 +165,21 @@ function isTaskBlockSource(value: unknown): value is TaskBlockSource {
118
165
  }
119
166
 
120
167
  function positiveInteger(value: unknown): number | undefined {
121
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
168
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= MAX_TASK_ID ? value : undefined;
169
+ }
170
+
171
+ const ANSI_ESCAPE_PATTERN = /\u001b(?:\][^\u0007]*(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~])/g;
172
+
173
+ /** Keep task data safe for TUI rendering and compact enough for session context. */
174
+ export function sanitizeTaskText(value: string): string {
175
+ return value.replace(ANSI_ESCAPE_PATTERN, "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
122
176
  }
123
177
 
124
178
  function boundedText(value: unknown, field: string, max = MAX_FIELD_LENGTH): string {
125
179
  if (typeof value !== "string") {
126
180
  throw new TaskListError("invalid_argument", `${field} 必须是字符串`);
127
181
  }
128
- const text = value.trim();
182
+ const text = sanitizeTaskText(value);
129
183
  if (text.length > max) {
130
184
  throw new TaskListError("invalid_argument", `${field} 不能超过 ${max} 个字符`);
131
185
  }
@@ -177,7 +231,7 @@ function safeStringList(value: unknown): string[] {
177
231
  if (!Array.isArray(value)) return [];
178
232
  return value
179
233
  .filter((item): item is string => typeof item === "string")
180
- .map((item) => item.trim().slice(0, MAX_FIELD_LENGTH))
234
+ .map((item) => sanitizeTaskText(item).slice(0, MAX_FIELD_LENGTH))
181
235
  .filter(Boolean)
182
236
  .filter((item, index, list) => list.indexOf(item) === index)
183
237
  .slice(0, MAX_LIST_ITEMS);
@@ -192,13 +246,43 @@ function safeIdList(value: unknown): number[] {
192
246
  .slice(0, MAX_LIST_ITEMS);
193
247
  }
194
248
 
249
+ function nextTaskId(id: number): number {
250
+ return id === MAX_TASK_ID ? 1 : id + 1;
251
+ }
252
+
253
+ function findAvailableTaskId(preferred: number, reserved: ReadonlySet<number>): number {
254
+ let candidate = Math.min(Math.max(preferred, 1), MAX_TASK_ID);
255
+ for (let attempts = 0; attempts <= reserved.size; attempts += 1) {
256
+ if (!reserved.has(candidate)) return candidate;
257
+ candidate = nextTaskId(candidate);
258
+ }
259
+ throw new TaskListError("task_limit", "没有可用的安全任务 ID");
260
+ }
261
+
262
+ function reservedTaskIds(tasks: readonly Task[]): Set<number> {
263
+ const reserved = new Set<number>();
264
+ for (const task of tasks) {
265
+ reserved.add(task.id);
266
+ for (const dependency of task.blockedBy) reserved.add(dependency);
267
+ }
268
+ return reserved;
269
+ }
270
+
271
+ function allocateTaskId(state: TaskState): number {
272
+ const reserved = reservedTaskIds(state.tasks);
273
+ const id = findAvailableTaskId(state.nextId, reserved);
274
+ reserved.add(id);
275
+ state.nextId = findAvailableTaskId(nextTaskId(id), reserved);
276
+ return id;
277
+ }
278
+
195
279
  function safeNotes(value: unknown): TaskNote[] {
196
280
  if (!Array.isArray(value)) return [];
197
281
  return value
198
282
  .filter(isRecord)
199
283
  .map((item) => ({
200
- at: typeof item.at === "string" ? item.at : "",
201
- text: typeof item.text === "string" ? item.text.trim().slice(0, MAX_FIELD_LENGTH) : "",
284
+ at: typeof item.at === "string" ? sanitizeTaskText(item.at).slice(0, 64) : "",
285
+ text: typeof item.text === "string" ? sanitizeTaskText(item.text).slice(0, MAX_FIELD_LENGTH) : "",
202
286
  }))
203
287
  .filter((item) => item.text.length > 0)
204
288
  .slice(-MAX_NOTES);
@@ -222,6 +306,41 @@ export function cloneState(state: TaskState): TaskState {
222
306
  };
223
307
  }
224
308
 
309
+ export function cloneTaskCommand(command: TaskCommand): TaskCommand {
310
+ const cloned: TaskCommand = { action: command.action };
311
+ if (command.id !== undefined) cloned.id = command.id;
312
+ if (command.subject !== undefined) cloned.subject = command.subject;
313
+ if (command.description !== undefined) cloned.description = command.description;
314
+ if (command.activeForm !== undefined) cloned.activeForm = command.activeForm;
315
+ if (command.acceptanceCriteria !== undefined) cloned.acceptanceCriteria = [...command.acceptanceCriteria];
316
+ if (command.evidence !== undefined) cloned.evidence = [...command.evidence];
317
+ if (command.addEvidence !== undefined) cloned.addEvidence = [...command.addEvidence];
318
+ if (command.status !== undefined) cloned.status = command.status;
319
+ if (command.priority !== undefined) cloned.priority = command.priority;
320
+ if (command.blockedBy !== undefined) cloned.blockedBy = [...command.blockedBy];
321
+ if (command.addBlockedBy !== undefined) cloned.addBlockedBy = [...command.addBlockedBy];
322
+ if (command.removeBlockedBy !== undefined) cloned.removeBlockedBy = [...command.removeBlockedBy];
323
+ if (command.blockReason !== undefined) cloned.blockReason = command.blockReason;
324
+ if (command.tags !== undefined) cloned.tags = [...command.tags];
325
+ if (command.addTags !== undefined) cloned.addTags = [...command.addTags];
326
+ if (command.removeTags !== undefined) cloned.removeTags = [...command.removeTags];
327
+ if (command.owner !== undefined) cloned.owner = command.owner;
328
+ if (command.completionNote !== undefined) cloned.completionNote = command.completionNote;
329
+ if (command.note !== undefined) cloned.note = command.note;
330
+ if (command.query !== undefined) cloned.query = command.query;
331
+ if (command.includeCancelled !== undefined) cloned.includeCancelled = command.includeCancelled;
332
+ if (command.scope !== undefined) cloned.scope = command.scope;
333
+ if (command.confirm !== undefined) cloned.confirm = command.confirm;
334
+ return cloned;
335
+ }
336
+
337
+ export function createTaskOperation(command: TaskCommand, at: string): TaskOperation {
338
+ if (command.action !== "create" && command.action !== "update" && command.action !== "clear") {
339
+ throw new TaskListError("invalid_argument", "只有 create、update、clear 可以记录为任务 operation");
340
+ }
341
+ return { version: 1, command: cloneTaskCommand(command), at: sanitizeTaskText(at).slice(0, 64) };
342
+ }
343
+
225
344
  export function createEmptyState(now = new Date().toISOString()): TaskState {
226
345
  return {
227
346
  version: TASK_LIST_VERSION,
@@ -236,32 +355,48 @@ export function createEmptyState(now = new Date().toISOString()): TaskState {
236
355
  export function normalizeState(value: unknown): TaskState {
237
356
  if (!isRecord(value)) return createEmptyState();
238
357
 
239
- const rawTasks = Array.isArray(value.tasks) ? value.tasks : [];
358
+ const rawTasks = Array.isArray(value.tasks) ? value.tasks.slice(0, MAX_TASKS) : [];
240
359
  const tasks: Task[] = [];
241
360
  const seenIds = new Set<number>();
361
+ const reservedExplicitIds = new Set(
362
+ rawTasks
363
+ .filter(isRecord)
364
+ .map((rawTask) => positiveInteger(rawTask.id))
365
+ .filter((id): id is number => id !== undefined),
366
+ );
367
+ const reservedDependencyIds = new Set(
368
+ rawTasks.flatMap((rawTask) => (isRecord(rawTask) ? safeIdList(rawTask.blockedBy) : [])),
369
+ );
370
+ const reservedReferenceIds = new Set([...reservedExplicitIds, ...reservedDependencyIds]);
242
371
  let fallbackId = 1;
243
372
 
373
+ const nextAvailableId = (): number => {
374
+ const reserved = new Set([...reservedReferenceIds, ...seenIds]);
375
+ const id = findAvailableTaskId(fallbackId, reserved);
376
+ fallbackId = nextTaskId(id);
377
+ return id;
378
+ };
379
+
244
380
  for (const rawTask of rawTasks) {
245
381
  if (!isRecord(rawTask)) continue;
246
382
  const rawId = positiveInteger(rawTask.id);
247
- const id = rawId ?? fallbackId;
248
- fallbackId = Math.max(fallbackId, id + 1);
249
- if (seenIds.has(id)) continue;
383
+ const id = rawId !== undefined && !seenIds.has(rawId) ? rawId : nextAvailableId();
250
384
  seenIds.add(id);
385
+ if (id < MAX_TASK_ID) fallbackId = Math.max(fallbackId, id + 1);
251
386
 
252
387
  const subject =
253
- typeof rawTask.subject === "string" && rawTask.subject.trim()
254
- ? rawTask.subject.trim().slice(0, MAX_SUBJECT_LENGTH)
388
+ typeof rawTask.subject === "string" && sanitizeTaskText(rawTask.subject)
389
+ ? sanitizeTaskText(rawTask.subject).slice(0, MAX_SUBJECT_LENGTH)
255
390
  : `Task #${id}`;
256
391
  const status = isTaskStatus(rawTask.status) ? rawTask.status : "pending";
257
392
  const priority = isTaskPriority(rawTask.priority) ? rawTask.priority : "medium";
258
- const description = typeof rawTask.description === "string" ? rawTask.description.trim().slice(0, MAX_FIELD_LENGTH) : "";
393
+ const description = typeof rawTask.description === "string" ? sanitizeTaskText(rawTask.description).slice(0, MAX_FIELD_LENGTH) : "";
259
394
  const activeForm =
260
- typeof rawTask.activeForm === "string" && rawTask.activeForm.trim()
261
- ? rawTask.activeForm.trim().slice(0, MAX_SUBJECT_LENGTH)
395
+ typeof rawTask.activeForm === "string" && sanitizeTaskText(rawTask.activeForm)
396
+ ? sanitizeTaskText(rawTask.activeForm).slice(0, MAX_SUBJECT_LENGTH)
262
397
  : subject;
263
398
  const blockedBy = safeIdList(rawTask.blockedBy);
264
- const blockReason = typeof rawTask.blockReason === "string" ? rawTask.blockReason.trim().slice(0, MAX_FIELD_LENGTH) : "";
399
+ const blockReason = typeof rawTask.blockReason === "string" ? sanitizeTaskText(rawTask.blockReason).slice(0, MAX_FIELD_LENGTH) : "";
265
400
  const inferredBlockSource: TaskBlockSource =
266
401
  status !== "blocked"
267
402
  ? ""
@@ -283,26 +418,46 @@ export function normalizeState(value: unknown): TaskState {
283
418
  blockReason,
284
419
  blockSource: status === "blocked" ? blockSource : "",
285
420
  tags: safeStringList(rawTask.tags),
286
- owner: typeof rawTask.owner === "string" ? rawTask.owner.trim().slice(0, 200) : "",
421
+ owner: typeof rawTask.owner === "string" ? sanitizeTaskText(rawTask.owner).slice(0, 200) : "",
287
422
  completionNote:
288
- typeof rawTask.completionNote === "string" ? rawTask.completionNote.trim().slice(0, MAX_FIELD_LENGTH) : "",
423
+ typeof rawTask.completionNote === "string" ? sanitizeTaskText(rawTask.completionNote).slice(0, MAX_FIELD_LENGTH) : "",
289
424
  notes: safeNotes(rawTask.notes),
290
- createdAt: typeof rawTask.createdAt === "string" ? rawTask.createdAt : "",
291
- updatedAt: typeof rawTask.updatedAt === "string" ? rawTask.updatedAt : "",
425
+ createdAt: typeof rawTask.createdAt === "string" ? sanitizeTaskText(rawTask.createdAt).slice(0, 64) : "",
426
+ updatedAt: typeof rawTask.updatedAt === "string" ? sanitizeTaskText(rawTask.updatedAt).slice(0, 64) : "",
292
427
  });
293
428
  }
294
429
 
295
430
  const rawNextId = positiveInteger(value.nextId) ?? 1;
296
431
  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();
432
+ const updatedAt =
433
+ typeof value.updatedAt === "string" && sanitizeTaskText(value.updatedAt)
434
+ ? sanitizeTaskText(value.updatedAt).slice(0, 64)
435
+ : new Date().toISOString();
298
436
 
299
437
  const state: TaskState = {
300
438
  version: TASK_LIST_VERSION,
301
439
  revision: rawRevision,
302
- nextId: Math.max(rawNextId, fallbackId, ...tasks.map((task) => task.id + 1)),
440
+ nextId: findAvailableTaskId(Math.max(rawNextId, fallbackId), reservedTaskIds(tasks)),
303
441
  updatedAt,
304
442
  tasks,
305
443
  };
444
+
445
+ // Repair impossible persisted states before exposing them to the tool or UI.
446
+ for (const task of state.tasks) {
447
+ const unresolved = unresolvedDependencies(state, task);
448
+ if (task.status === "completed" && unresolved.length > 0) {
449
+ task.status = "blocked";
450
+ task.blockReason = dependencyReason(unresolved);
451
+ task.blockSource = "dependency";
452
+ } else if (task.status === "completed" && task.evidence.length < task.acceptanceCriteria.length) {
453
+ task.status = "pending";
454
+ task.blockReason = "";
455
+ task.blockSource = "";
456
+ } else if (task.status === "blocked" && !task.blockReason && unresolved.length === 0) {
457
+ task.status = "pending";
458
+ task.blockSource = "";
459
+ }
460
+ }
306
461
  reconcileDependencyBlocks(state, updatedAt, false);
307
462
  return state;
308
463
  }
@@ -334,33 +489,34 @@ function validateReferences(state: TaskState, ids: number[], selfId?: number): v
334
489
  function validateNoCycle(state: TaskState, taskId: number, dependencies: number[]): void {
335
490
  const graph = new Map(state.tasks.map((task) => [task.id, task.blockedBy]));
336
491
  graph.set(taskId, dependencies);
492
+ const visited = new Set<number>();
493
+ const stack = [...dependencies];
337
494
 
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())) {
495
+ while (stack.length > 0) {
496
+ const current = stack.pop()!;
497
+ if (current === taskId) {
350
498
  throw new TaskListError("dependency_cycle", `任务 #${taskId} 的依赖会形成循环`);
351
499
  }
500
+ if (visited.has(current)) continue;
501
+ visited.add(current);
502
+ stack.push(...(graph.get(current) ?? []));
352
503
  }
353
504
  }
354
505
 
355
- function unresolvedDependencies(state: TaskState, task: Task): Task[] {
356
- const resolved = new Set(["completed", "cancelled"]);
506
+ interface DependencyReference {
507
+ id: number;
508
+ status: TaskStatus;
509
+ missing?: boolean;
510
+ }
511
+
512
+ function unresolvedDependencies(state: TaskState, task: Task): DependencyReference[] {
357
513
  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));
514
+ .map((id) => state.tasks.find((candidate) => candidate.id === id) ?? { id, status: "blocked" as const, missing: true })
515
+ .filter((candidate) => candidate.status !== "completed");
360
516
  }
361
517
 
362
- function dependencyReason(tasks: Task[]): string {
363
- return `等待任务 ${tasks.map((task) => `#${task.id}`).join(", ")} 完成`;
518
+ function dependencyReason(tasks: DependencyReference[]): string {
519
+ return `等待任务 ${tasks.map((task) => `#${task.id}${task.missing ? "(不存在)" : ""}`).join(", ")} 完成`;
364
520
  }
365
521
 
366
522
  function normalizeStatus(value: unknown): TaskStatus {
@@ -382,12 +538,23 @@ function appendNote(task: Task, text: string, at: string): void {
382
538
  if (task.notes.length > MAX_NOTES) task.notes = task.notes.slice(-MAX_NOTES);
383
539
  }
384
540
 
541
+ function appendUniqueBounded<T>(target: T[], additions: T[], field: string): void {
542
+ const uniqueAdditions = additions.filter((item) => !target.includes(item));
543
+ if (target.length + uniqueAdditions.length > MAX_LIST_ITEMS) {
544
+ throw new TaskListError("invalid_argument", `${field} 最多包含 ${MAX_LIST_ITEMS} 项`);
545
+ }
546
+ target.push(...uniqueAdditions);
547
+ }
548
+
385
549
  function touch(state: TaskState, now: string): void {
386
550
  state.revision += 1;
387
551
  state.updatedAt = now;
388
552
  }
389
553
 
390
554
  function makeTask(state: TaskState, params: TaskCommand, now: string): Task {
555
+ if (state.tasks.length >= MAX_TASKS) {
556
+ throw new TaskListError("task_limit", `任务数量已达到上限 ${MAX_TASKS},请先清理已完成任务`);
557
+ }
391
558
  const subject = requiredText(params.subject, "subject", MAX_SUBJECT_LENGTH);
392
559
  const requestedStatus = params.status === undefined ? "pending" : normalizeStatus(params.status);
393
560
  const priority = params.priority === undefined ? "medium" : normalizePriority(params.priority);
@@ -398,12 +565,15 @@ function makeTask(state: TaskState, params: TaskCommand, now: string): Task {
398
565
  throw new TaskListError("invalid_transition", "新任务只能从 pending、in_progress 或 blocked 开始");
399
566
  }
400
567
 
568
+ const id = allocateTaskId(state);
569
+ validateNoCycle(state, id, blockedBy);
570
+
401
571
  const task: Task = {
402
- id: state.nextId++,
572
+ id,
403
573
  subject,
404
574
  description: params.description === undefined ? "" : boundedText(params.description, "description"),
405
575
  activeForm:
406
- params.activeForm === undefined || !params.activeForm.trim()
576
+ params.activeForm === undefined || (typeof params.activeForm === "string" && !params.activeForm.trim())
407
577
  ? subject
408
578
  : boundedText(params.activeForm, "activeForm", MAX_SUBJECT_LENGTH),
409
579
  acceptanceCriteria: params.acceptanceCriteria === undefined ? [] : stringList(params.acceptanceCriteria, "acceptanceCriteria"),
@@ -467,16 +637,12 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
467
637
  }
468
638
  if (params.evidence !== undefined) candidate.evidence = stringList(params.evidence, "evidence");
469
639
  if (params.addEvidence !== undefined) {
470
- for (const item of stringList(params.addEvidence, "addEvidence")) {
471
- if (!candidate.evidence.includes(item)) candidate.evidence.push(item);
472
- }
640
+ appendUniqueBounded(candidate.evidence, stringList(params.addEvidence, "addEvidence"), "evidence");
473
641
  }
474
642
  if (params.priority !== undefined) candidate.priority = normalizePriority(params.priority);
475
643
  if (params.blockedBy !== undefined) candidate.blockedBy = idList(params.blockedBy, "blockedBy");
476
644
  if (params.addBlockedBy !== undefined) {
477
- for (const id of idList(params.addBlockedBy, "addBlockedBy")) {
478
- if (!candidate.blockedBy.includes(id)) candidate.blockedBy.push(id);
479
- }
645
+ appendUniqueBounded(candidate.blockedBy, idList(params.addBlockedBy, "addBlockedBy"), "blockedBy");
480
646
  }
481
647
  if (params.removeBlockedBy !== undefined) {
482
648
  const remove = new Set(idList(params.removeBlockedBy, "removeBlockedBy"));
@@ -488,9 +654,7 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
488
654
  }
489
655
  if (params.tags !== undefined) candidate.tags = stringList(params.tags, "tags");
490
656
  if (params.addTags !== undefined) {
491
- for (const tag of stringList(params.addTags, "addTags")) {
492
- if (!candidate.tags.includes(tag)) candidate.tags.push(tag);
493
- }
657
+ appendUniqueBounded(candidate.tags, stringList(params.addTags, "addTags"), "tags");
494
658
  }
495
659
  if (params.removeTags !== undefined) {
496
660
  const remove = new Set(stringList(params.removeTags, "removeTags"));
@@ -561,49 +725,66 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
561
725
  }
562
726
 
563
727
  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";
728
+ const changedIds = new Set<number>();
729
+ let passChanged = true;
730
+
731
+ // Reconcile to a fixed point so a reopened dependency cascades through the whole graph.
732
+ while (passChanged) {
733
+ passChanged = false;
734
+ for (const task of state.tasks) {
735
+ if (task.status === "cancelled" || task.blockSource === "manual") continue;
736
+ const before = cloneTask(task);
737
+ const unresolved = unresolvedDependencies(state, task);
738
+
739
+ if (unresolved.length > 0) {
740
+ if (task.status === "pending" || task.status === "in_progress" || task.status === "completed" || task.status === "blocked") {
741
+ task.status = "blocked";
742
+ task.blockReason = dependencyReason(unresolved);
743
+ task.blockSource = "dependency";
744
+ }
745
+ } else if (task.status === "blocked" && task.blockSource === "dependency") {
746
+ task.status = "pending";
747
+ task.blockReason = "";
748
+ task.blockSource = "";
576
749
  }
577
- } else if (task.status === "blocked" && task.blockSource === "dependency") {
578
- task.status = "pending";
579
- task.blockReason = "";
580
- task.blockSource = "";
581
- }
582
750
 
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);
751
+ if (JSON.stringify(task) === JSON.stringify(before)) continue;
752
+ if (recordNotes && task.status !== before.status) {
753
+ const note = task.status === "blocked" ? `依赖未完成,自动标记为 blocked:${task.blockReason}` : "依赖已完成,自动恢复为 pending";
754
+ appendNote(task, note, now);
755
+ }
756
+ task.updatedAt = now;
757
+ changedIds.add(task.id);
758
+ passChanged = true;
587
759
  }
588
- task.updatedAt = now;
589
- changedIds.push(task.id);
590
760
  }
591
761
 
592
- return changedIds;
762
+ return [...changedIds];
593
763
  }
594
764
 
595
765
  export function filterTasks(state: TaskState, params: Pick<TaskCommand, "status" | "priority" | "query" | "includeCancelled"> = {}): Task[] {
596
766
  const status = params.status === undefined ? undefined : normalizeStatus(params.status);
597
767
  const priority = params.priority === undefined ? undefined : normalizePriority(params.priority);
598
- const query = params.query?.trim().toLowerCase();
768
+ const query = params.query === undefined ? undefined : boundedText(params.query, "query", MAX_FIELD_LENGTH).toLowerCase();
599
769
 
600
770
  return state.tasks
601
- .filter((task) => params.includeCancelled === true || task.status !== "cancelled")
771
+ .filter((task) => params.includeCancelled === true || status === "cancelled" || task.status !== "cancelled")
602
772
  .filter((task) => status === undefined || task.status === status)
603
773
  .filter((task) => priority === undefined || task.priority === priority)
604
774
  .filter((task) => {
605
775
  if (!query) return true;
606
- return [task.subject, task.description, task.owner, ...task.tags].some((value) => value.toLowerCase().includes(query));
776
+ return [
777
+ task.subject,
778
+ task.description,
779
+ task.activeForm,
780
+ task.blockReason,
781
+ task.owner,
782
+ task.completionNote,
783
+ ...task.tags,
784
+ ...task.acceptanceCriteria,
785
+ ...task.evidence,
786
+ ...task.notes.map((note) => note.text),
787
+ ].some((value) => value.toLowerCase().includes(query));
607
788
  })
608
789
  .sort(compareTasks)
609
790
  .map(cloneTask);
@@ -719,17 +900,31 @@ export function applyTaskAction(current: TaskState, params: TaskCommand, now = n
719
900
  };
720
901
  }
721
902
  const removedIds = new Set(removed.map((task) => task.id));
903
+ const completedIds = new Set(removed.filter((task) => task.status === "completed").map((task) => task.id));
722
904
  state.tasks = scope === "all" ? [] : state.tasks.filter((task) => !removedIds.has(task.id));
905
+
906
+ const referenceChanges = new Set<number>();
723
907
  for (const task of state.tasks) {
724
- task.blockedBy = task.blockedBy.filter((id) => !removedIds.has(id));
908
+ const removedCompletedDependencies = task.blockedBy.filter((id) => completedIds.has(id));
909
+ if (removedCompletedDependencies.length === 0) continue;
910
+ task.blockedBy = task.blockedBy.filter((id) => !completedIds.has(id));
911
+ task.updatedAt = now;
912
+ appendNote(task, `已清理完成的依赖任务 ${removedCompletedDependencies.map((id) => `#${id}`).join(", ")},移除依赖引用`, now);
913
+ referenceChanges.add(task.id);
725
914
  }
915
+
916
+ // References to cancelled tasks remain dangling on purpose: cancellation is not completion.
917
+ // This keeps downstream work blocked and reserves the removed ID against accidental reuse.
918
+ const dependencyChanges = reconcileDependencyBlocks(state, now);
919
+ const affectedIds = new Set([...referenceChanges, ...dependencyChanges]);
920
+ const changedIds = [...removed.map((task) => task.id), ...affectedIds];
726
921
  touch(state, now);
727
922
  return {
728
923
  action: "clear",
729
924
  state,
730
- changedIds: removed.map((task) => task.id),
731
- visibleTasks: state.tasks.map(cloneTask),
732
- message: `已清理 ${removed.length} 个任务(范围:${scope})`,
925
+ changedIds,
926
+ visibleTasks: state.tasks.filter((task) => affectedIds.has(task.id)).map(cloneTask),
927
+ message: `已清理 ${removed.length} 个任务(范围:${scope})${affectedIds.size > 0 ? `;同步更新 ${affectedIds.size} 个依赖任务` : ""}`,
733
928
  mutated: true,
734
929
  };
735
930
  }
@@ -753,9 +948,9 @@ export function priorityLabel(priority: TaskPriority): string {
753
948
  return { high: "高", medium: "中", low: "低" }[priority];
754
949
  }
755
950
 
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]));
951
+ export function summarizeState(state: TaskState, dependencyState: TaskState = state): TaskSummary {
952
+ const summary: TaskSummary = { total: 0, active: 0, blocked: 0, pending: 0, completed: 0, cancelled: 0, ready: 0 };
953
+ const statusById = new Map(dependencyState.tasks.map((task) => [task.id, task.status]));
759
954
 
760
955
  for (const task of state.tasks) {
761
956
  if (task.status === "cancelled") {
@@ -768,10 +963,7 @@ export function summarizeState(state: TaskState): { total: number; active: numbe
768
963
  if (task.status === "completed") summary.completed++;
769
964
  if (task.status === "pending") {
770
965
  summary.pending++;
771
- const dependenciesResolved = task.blockedBy.every((id) => {
772
- const status = statusById.get(id);
773
- return status === undefined || status === "completed" || status === "cancelled";
774
- });
966
+ const dependenciesResolved = task.blockedBy.every((id) => statusById.get(id) === "completed");
775
967
  if (dependenciesResolved) summary.ready++;
776
968
  }
777
969
  }