@pellux/goodvibes-tui 1.20.0 → 1.21.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
@@ -1,7 +1,69 @@
1
+ // ---------------------------------------------------------------------------
2
+ // work-plan-store.ts — per-project work plan persisted at
3
+ // <home>/.goodvibes/tui/work-plans/<projectId>.json
4
+ //
5
+ // Recovery discipline (applies to every read):
6
+ //
7
+ // * CONTENT, NOT EXISTENCE, decides whether the file is usable. A plan file
8
+ // can exist and still be unusable — zero-byte, truncated mid-write, or
9
+ // otherwise torn by a crash. Every read parses defensively and validates the
10
+ // parsed SHAPE; an unusable file degrades to an empty plan instead of
11
+ // throwing out of the store's public methods (which used to brick every
12
+ // /work-plan operation until the user deleted the file by hand).
13
+ // * AN UNREADABLE FILE IS PRESERVED, NOT OVERWRITTEN. It is renamed aside to
14
+ // `<plan>.json.corrupt-<ms>-<rand>` so a user can still recover their list.
15
+ // Those quarantine copies are themselves bounded (age TTL + count cap) so
16
+ // the recovery path cannot become its own leak.
17
+ // * THE STORE IS BOUNDED. Terminal (done/cancelled) items age out and are
18
+ // capped by count. Items the user still has open — pending, in_progress,
19
+ // blocked, failed — are NEVER reclaimed by either bound: garbage collection
20
+ // must not delete live work.
21
+ // * NOTHING IS DELETED SILENTLY. Whatever a sweep reclaimed is recorded on the
22
+ // plan as `housekeeping` and rendered by `toMarkdown()` (which /work-plan
23
+ // show prints), so a reclaim is distinguishable from data loss.
24
+ //
25
+ // Sweeps are idempotent and safe to run from more than one process: removal is
26
+ // computed from the file's own contents, writes go through atomicWriteFileSync
27
+ // (rename(2)), and unlink races are treated as already-done.
28
+ // ---------------------------------------------------------------------------
29
+
1
30
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
31
+ import { readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
3
32
  import { atomicWriteFileSync } from '@/config/atomic-write.ts';
4
- import { join } from 'node:path';
33
+ import { basename, dirname, join } from 'node:path';
34
+
35
+ /**
36
+ * Age TTL for TERMINAL (done/cancelled) items. Once a completed or cancelled
37
+ * item has been untouched this long it is reclaimed by the next read. Chosen to
38
+ * comfortably outlive "I finished that last month and want to see it" while
39
+ * still bounding a plan that is never manually cleared.
40
+ */
41
+ export const WORK_PLAN_TERMINAL_ITEM_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
42
+
43
+ /**
44
+ * Count cap for TERMINAL (done/cancelled) items: the most recently completed
45
+ * this many are kept, older ones are reclaimed oldest-first. Live items are
46
+ * neither counted against this cap nor removed by it.
47
+ */
48
+ export const WORK_PLAN_TERMINAL_ITEM_CAP = 200;
49
+
50
+ /** Age TTL for quarantined (unreadable) plan files — the recovery copy's own bound. */
51
+ export const WORK_PLAN_QUARANTINE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
52
+
53
+ /** Count cap for quarantined plan files kept alongside one plan; oldest go first. */
54
+ export const WORK_PLAN_QUARANTINE_CAP = 5;
55
+
56
+ /**
57
+ * Minimum gap between quarantine-directory scans for one store instance. The
58
+ * item bounds are evaluated on every read (they are a pure pass over in-memory
59
+ * items); the directory scan is throttled so a redrawing modal cannot turn it
60
+ * into a per-frame readdir. This is what makes the sweep periodic rather than
61
+ * startup-only: a long-lived session keeps sweeping on this cadence.
62
+ */
63
+ export const WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
64
+
65
+ /** Filename marker for a preserved copy of a plan file that could not be parsed. */
66
+ const QUARANTINE_SUFFIX = '.corrupt-';
5
67
 
6
68
  export const WORK_PLAN_STATUSES = [
7
69
  'pending',
@@ -34,6 +96,26 @@ export interface WorkPlanItem {
34
96
  readonly completedAt?: number;
35
97
  }
36
98
 
99
+ /**
100
+ * What the last recovery/bounding sweep reclaimed. Persisted with the plan and
101
+ * rendered by `toMarkdown()` so a removal is always disclosed — counts and
102
+ * paths only, never item text.
103
+ */
104
+ export interface WorkPlanHousekeeping {
105
+ /** Unix ms of the sweep that produced these counts. */
106
+ readonly at: number;
107
+ /** Terminal items reclaimed by the age TTL. */
108
+ readonly expiredItems: number;
109
+ /** Terminal items reclaimed by the count cap. */
110
+ readonly cappedItems: number;
111
+ /** True when the plan file on disk could not be parsed and the plan was reset. */
112
+ readonly resetFromUnreadableFile?: boolean;
113
+ /** Where the unreadable file was preserved, when there was content worth keeping. */
114
+ readonly quarantinePath?: string;
115
+ /** Quarantined files removed by their own TTL / count cap. */
116
+ readonly quarantinesRemoved?: number;
117
+ }
118
+
37
119
  export interface WorkPlan {
38
120
  readonly id: string;
39
121
  readonly projectId: string;
@@ -44,6 +126,8 @@ export interface WorkPlan {
44
126
  readonly source?: string;
45
127
  readonly createdAt: number;
46
128
  readonly updatedAt: number;
129
+ /** Result of the most recent sweep that actually reclaimed something. */
130
+ readonly housekeeping?: WorkPlanHousekeeping;
47
131
  }
48
132
 
49
133
  export interface WorkPlanStoreOptions {
@@ -147,6 +231,70 @@ function formatStatus(status: WorkPlanItemStatus): string {
147
231
  return status.replace(/_/g, ' ');
148
232
  }
149
233
 
234
+ /**
235
+ * Terminal = the user is done with it. Only these two statuses are eligible for
236
+ * the age TTL and the count cap. `blocked` and `failed` still need attention and
237
+ * `pending`/`in_progress` are live, so none of them are ever auto-reclaimed.
238
+ */
239
+ function isTerminalStatus(status: WorkPlanItemStatus): boolean {
240
+ return status === 'done' || status === 'cancelled';
241
+ }
242
+
243
+ /** The timestamp a terminal item is aged from. */
244
+ function terminalAgeStamp(item: WorkPlanItem): number {
245
+ return item.completedAt ?? item.updatedAt ?? item.createdAt;
246
+ }
247
+
248
+ function normalizeHousekeeping(value: unknown): WorkPlanHousekeeping | undefined {
249
+ if (!isObject(value)) return undefined;
250
+ if (typeof value.at !== 'number') return undefined;
251
+ const expiredItems = typeof value.expiredItems === 'number' ? value.expiredItems : 0;
252
+ const cappedItems = typeof value.cappedItems === 'number' ? value.cappedItems : 0;
253
+ const quarantinePath = readString(value.quarantinePath);
254
+ const quarantinesRemoved = typeof value.quarantinesRemoved === 'number' ? value.quarantinesRemoved : undefined;
255
+ return {
256
+ at: value.at,
257
+ expiredItems,
258
+ cappedItems,
259
+ ...(value.resetFromUnreadableFile === true ? { resetFromUnreadableFile: true } : {}),
260
+ ...(quarantinePath ? { quarantinePath } : {}),
261
+ ...(quarantinesRemoved !== undefined ? { quarantinesRemoved } : {}),
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Parse a plan file's CONTENT. Returns null for anything that is not a JSON
267
+ * object — empty/whitespace-only (a zero-byte file left by a crash), truncated
268
+ * JSON, or a valid JSON scalar/array. Never throws.
269
+ */
270
+ function parsePlanDocument(raw: string): Record<string, unknown> | null {
271
+ if (raw.trim().length === 0) return null;
272
+ let parsed: unknown;
273
+ try {
274
+ parsed = JSON.parse(raw) as unknown;
275
+ } catch {
276
+ return null;
277
+ }
278
+ return isObject(parsed) ? parsed : null;
279
+ }
280
+
281
+ /** One-line, content-free disclosure of what the last sweep reclaimed. */
282
+ function formatHousekeeping(record: WorkPlanHousekeeping): string {
283
+ const parts: string[] = [];
284
+ if (record.resetFromUnreadableFile) {
285
+ parts.push(record.quarantinePath
286
+ ? `the previous plan file could not be read and was preserved at ${record.quarantinePath}`
287
+ : 'the previous plan file was empty or unreadable and was reset');
288
+ }
289
+ if (record.expiredItems > 0) parts.push(`${record.expiredItems} completed item(s) aged out`);
290
+ if (record.cappedItems > 0) parts.push(`${record.cappedItems} completed item(s) over the retention cap removed`);
291
+ if (record.quarantinesRemoved && record.quarantinesRemoved > 0) {
292
+ parts.push(`${record.quarantinesRemoved} old quarantined plan file(s) deleted`);
293
+ }
294
+ if (parts.length === 0) parts.push('nothing reclaimed');
295
+ return `Housekeeping (${new Date(record.at).toISOString()}): ${parts.join('; ')}.`;
296
+ }
297
+
150
298
  export function nextWorkPlanStatus(status: WorkPlanItemStatus): WorkPlanItemStatus {
151
299
  switch (status) {
152
300
  case 'pending':
@@ -165,6 +313,12 @@ export function nextWorkPlanStatus(status: WorkPlanItemStatus): WorkPlanItemStat
165
313
  export class WorkPlanStore {
166
314
  readonly filePath: string;
167
315
 
316
+ /**
317
+ * Unix ms of this instance's last quarantine-directory scan. Zero means the
318
+ * first read of the session sweeps — that read IS the recovery point.
319
+ */
320
+ private lastQuarantineSweepAt = 0;
321
+
168
322
  constructor(private readonly options: WorkPlanStoreOptions) {
169
323
  const fileName = `${safeFileId(options.projectId, options.projectRoot)}.json`;
170
324
  this.filePath = join(options.homeDirectory, '.goodvibes', 'tui', 'work-plans', fileName);
@@ -287,8 +441,11 @@ export class WorkPlanStore {
287
441
  `Project: ${plan.projectRoot}`,
288
442
  `Project ID: ${plan.projectId}`,
289
443
  `Updated: ${new Date(plan.updatedAt).toISOString()}`,
290
- '',
291
444
  ];
445
+ // Disclosure: whatever the last sweep reclaimed is stated here, so a
446
+ // removal can never be mistaken for silent data loss.
447
+ if (plan.housekeeping) lines.push(formatHousekeeping(plan.housekeeping));
448
+ lines.push('');
292
449
  if (plan.items.length === 0) {
293
450
  lines.push('No work plan items recorded.');
294
451
  return lines.join('\n');
@@ -304,11 +461,49 @@ export class WorkPlanStore {
304
461
  return lines.join('\n');
305
462
  }
306
463
 
464
+ /**
465
+ * The plan as callers see it: loaded from disk (content-validated), then put
466
+ * through the recovery/bounding sweep. When the sweep reclaimed anything the
467
+ * bounded plan — carrying its housekeeping disclosure — is written back
468
+ * immediately, so the disclosure survives even if the caller only reads.
469
+ */
307
470
  private readPlan(): WorkPlan {
308
- if (!existsSync(this.filePath)) return this.createEmptyPlan();
309
- const raw = readFileSync(this.filePath, 'utf8');
310
- const parsed = JSON.parse(raw) as unknown;
311
- if (!isObject(parsed)) return this.createEmptyPlan();
471
+ const loaded = this.loadFromDisk();
472
+ const swept = this.sweep(loaded.plan, loaded.recovery);
473
+ if (swept.changed) this.writePlan(swept.plan);
474
+ return swept.plan;
475
+ }
476
+
477
+ /**
478
+ * Read and validate the plan file's CONTENT. A missing file is the ordinary
479
+ * empty case. A file that exists but does not parse into a plan object is
480
+ * moved aside (never overwritten in place) and reported as a recovery.
481
+ */
482
+ private loadFromDisk(): { plan: WorkPlan; recovery: WorkPlanHousekeeping | null } {
483
+ let raw: string;
484
+ try {
485
+ raw = readFileSync(this.filePath, 'utf8');
486
+ } catch {
487
+ // No file yet (the common first-run case), or it is unreadable right now.
488
+ // Nothing existed to preserve, so there is nothing to disclose.
489
+ return { plan: this.createEmptyPlan(), recovery: null };
490
+ }
491
+ const parsed = parsePlanDocument(raw);
492
+ if (parsed === null) {
493
+ // The file EXISTED but its contents are not a usable plan: zero-byte,
494
+ // truncated mid-write, or otherwise torn. Existence is not validity.
495
+ const quarantinePath = raw.trim().length > 0 ? this.quarantineUnreadableFile() : undefined;
496
+ return {
497
+ plan: this.createEmptyPlan(),
498
+ recovery: {
499
+ at: nowMs(),
500
+ expiredItems: 0,
501
+ cappedItems: 0,
502
+ resetFromUnreadableFile: true,
503
+ ...(quarantinePath ? { quarantinePath } : {}),
504
+ },
505
+ };
506
+ }
312
507
  const time = nowMs();
313
508
  const createdAt = typeof parsed.createdAt === 'number' ? parsed.createdAt : time;
314
509
  const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : createdAt;
@@ -317,19 +512,142 @@ export class WorkPlanStore {
317
512
  : [];
318
513
  const activeItemId = readString(parsed.activeItemId);
319
514
  const source = readString(parsed.source);
515
+ const housekeeping = normalizeHousekeeping(parsed.housekeeping);
516
+ return {
517
+ plan: {
518
+ id: readString(parsed.id) ?? createPlanId(this.options.projectId, this.options.projectRoot),
519
+ projectId: readString(parsed.projectId) ?? this.options.projectId,
520
+ projectRoot: readString(parsed.projectRoot) ?? this.options.projectRoot,
521
+ title: readString(parsed.title) ?? 'Work Plan',
522
+ items,
523
+ ...(activeItemId && items.some((item) => item.id === activeItemId) ? { activeItemId } : {}),
524
+ ...(source ? { source } : {}),
525
+ createdAt,
526
+ updatedAt,
527
+ ...(housekeeping ? { housekeeping } : {}),
528
+ },
529
+ recovery: null,
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Apply both bounds (age TTL, then count cap) over TERMINAL items only, plus
535
+ * the throttled quarantine-file sweep. Pure with respect to disk except for
536
+ * quarantine deletions; the caller persists the result when `changed`.
537
+ *
538
+ * Idempotent: re-running it on the returned plan reclaims nothing further,
539
+ * which is what makes it safe to run concurrently from several processes.
540
+ */
541
+ private sweep(
542
+ plan: WorkPlan,
543
+ recovery: WorkPlanHousekeeping | null,
544
+ ): { plan: WorkPlan; changed: boolean } {
545
+ const now = nowMs();
546
+ const expired = new Set<WorkPlanItem>();
547
+ for (const item of plan.items) {
548
+ if (!isTerminalStatus(item.status)) continue;
549
+ if (now - terminalAgeStamp(item) > WORK_PLAN_TERMINAL_ITEM_TTL_MS) expired.add(item);
550
+ }
551
+ const survivors = plan.items.filter((item) => !expired.has(item));
552
+ // Count cap applies to terminal survivors only — newest completions kept.
553
+ const terminalSurvivors = survivors
554
+ .filter((item) => isTerminalStatus(item.status))
555
+ .sort((a, b) => terminalAgeStamp(b) - terminalAgeStamp(a));
556
+ const capped = new Set<WorkPlanItem>(terminalSurvivors.slice(WORK_PLAN_TERMINAL_ITEM_CAP));
557
+ const kept = survivors.filter((item) => !capped.has(item));
558
+ const quarantinesRemoved = this.sweepQuarantines(now);
559
+
560
+ const changed = expired.size > 0 || capped.size > 0 || recovery !== null || quarantinesRemoved > 0;
561
+ if (!changed) return { plan, changed: false };
562
+
563
+ const housekeeping: WorkPlanHousekeeping = {
564
+ at: now,
565
+ expiredItems: expired.size,
566
+ cappedItems: capped.size,
567
+ ...(recovery?.resetFromUnreadableFile ? { resetFromUnreadableFile: true } : {}),
568
+ ...(recovery?.quarantinePath ? { quarantinePath: recovery.quarantinePath } : {}),
569
+ ...(quarantinesRemoved > 0 ? { quarantinesRemoved } : {}),
570
+ };
571
+ const activeItemId = plan.activeItemId && kept.some((item) => item.id === plan.activeItemId)
572
+ ? plan.activeItemId
573
+ : undefined;
320
574
  return {
321
- id: readString(parsed.id) ?? createPlanId(this.options.projectId, this.options.projectRoot),
322
- projectId: readString(parsed.projectId) ?? this.options.projectId,
323
- projectRoot: readString(parsed.projectRoot) ?? this.options.projectRoot,
324
- title: readString(parsed.title) ?? 'Work Plan',
325
- items,
326
- ...(activeItemId && items.some((item) => item.id === activeItemId) ? { activeItemId } : {}),
327
- ...(source ? { source } : {}),
328
- createdAt,
329
- updatedAt,
575
+ plan: {
576
+ id: plan.id,
577
+ projectId: plan.projectId,
578
+ projectRoot: plan.projectRoot,
579
+ title: plan.title,
580
+ items: kept,
581
+ ...(activeItemId ? { activeItemId } : {}),
582
+ ...(plan.source ? { source: plan.source } : {}),
583
+ createdAt: plan.createdAt,
584
+ // `updatedAt` tracks the user's edits; a garbage-collection pass is not
585
+ // one, so it is deliberately left alone. housekeeping.at dates the sweep.
586
+ updatedAt: plan.updatedAt,
587
+ housekeeping,
588
+ },
589
+ changed: true,
330
590
  };
331
591
  }
332
592
 
593
+ /**
594
+ * Move an unparseable plan file aside so the user can still recover it.
595
+ * Returns the quarantine path, or undefined if the rename did not happen
596
+ * (already moved by a concurrent process, or the directory is not writable).
597
+ */
598
+ private quarantineUnreadableFile(): string | undefined {
599
+ const target = `${this.filePath}${QUARANTINE_SUFFIX}${nowMs()}-${randomUUID().slice(0, 8)}`;
600
+ try {
601
+ renameSync(this.filePath, target);
602
+ return target;
603
+ } catch {
604
+ // Gone already (another process quarantined it first) or unwritable —
605
+ // either way the plan still degrades to empty rather than throwing.
606
+ return undefined;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Bound the quarantine copies: delete any past WORK_PLAN_QUARANTINE_TTL_MS
612
+ * and any beyond WORK_PLAN_QUARANTINE_CAP (oldest first). Throttled to
613
+ * WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS per store instance so a long-lived
614
+ * session keeps sweeping without scanning the directory on every read.
615
+ */
616
+ private sweepQuarantines(now: number): number {
617
+ if (now - this.lastQuarantineSweepAt < WORK_PLAN_QUARANTINE_SWEEP_INTERVAL_MS) return 0;
618
+ this.lastQuarantineSweepAt = now;
619
+ const directory = dirname(this.filePath);
620
+ const prefix = `${basename(this.filePath)}${QUARANTINE_SUFFIX}`;
621
+ let entries: string[];
622
+ try {
623
+ entries = readdirSync(directory);
624
+ } catch {
625
+ return 0;
626
+ }
627
+ const candidates = entries
628
+ .filter((entry) => entry.startsWith(prefix))
629
+ .map((entry) => {
630
+ const stamp = Number.parseInt(entry.slice(prefix.length).split('-')[0] ?? '', 10);
631
+ return { entry, at: Number.isFinite(stamp) ? stamp : 0 };
632
+ })
633
+ .sort((a, b) => b.at - a.at); // newest first
634
+ let removed = 0;
635
+ for (let index = 0; index < candidates.length; index += 1) {
636
+ const candidate = candidates[index]!;
637
+ const tooOld = now - candidate.at > WORK_PLAN_QUARANTINE_TTL_MS;
638
+ const overCap = index >= WORK_PLAN_QUARANTINE_CAP;
639
+ if (!tooOld && !overCap) continue;
640
+ try {
641
+ unlinkSync(join(directory, candidate.entry));
642
+ removed += 1;
643
+ } catch {
644
+ // Already removed by a concurrent sweep — idempotent, not an error, and
645
+ // not counted so the disclosed number stays truthful.
646
+ }
647
+ }
648
+ return removed;
649
+ }
650
+
333
651
  private createEmptyPlan(): WorkPlan {
334
652
  const time = nowMs();
335
653
  return {