crewly 1.20.358 → 1.20.360

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 (137) hide show
  1. package/config/roles/developer/prompt.md +3 -1
  2. package/config/skills/registry.json +125 -1
  3. package/dist/backend/backend/src/constants.d.ts +189 -2
  4. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  5. package/dist/backend/backend/src/constants.js +200 -3
  6. package/dist/backend/backend/src/constants.js.map +1 -1
  7. package/dist/backend/backend/src/index.d.ts +58 -0
  8. package/dist/backend/backend/src/index.d.ts.map +1 -1
  9. package/dist/backend/backend/src/index.js +263 -19
  10. package/dist/backend/backend/src/index.js.map +1 -1
  11. package/dist/backend/backend/src/services/agent/boot-pressure.d.ts +147 -0
  12. package/dist/backend/backend/src/services/agent/boot-pressure.d.ts.map +1 -0
  13. package/dist/backend/backend/src/services/agent/boot-pressure.js +212 -0
  14. package/dist/backend/backend/src/services/agent/boot-pressure.js.map +1 -0
  15. package/dist/backend/backend/src/services/agent/restore-queue.d.ts +50 -1
  16. package/dist/backend/backend/src/services/agent/restore-queue.d.ts.map +1 -1
  17. package/dist/backend/backend/src/services/agent/restore-queue.js +100 -5
  18. package/dist/backend/backend/src/services/agent/restore-queue.js.map +1 -1
  19. package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.d.ts +11 -0
  20. package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.d.ts.map +1 -1
  21. package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js +28 -2
  22. package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js.map +1 -1
  23. package/dist/backend/backend/src/services/memory/startup-briefing.service.d.ts +10 -0
  24. package/dist/backend/backend/src/services/memory/startup-briefing.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/memory/startup-briefing.service.js +65 -22
  26. package/dist/backend/backend/src/services/memory/startup-briefing.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/messaging/deferred-start-retry.d.ts +8 -0
  28. package/dist/backend/backend/src/services/messaging/deferred-start-retry.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/messaging/deferred-start-retry.js +8 -0
  30. package/dist/backend/backend/src/services/messaging/deferred-start-retry.js.map +1 -1
  31. package/dist/backend/backend/src/services/session/session-binding.d.ts +25 -2
  32. package/dist/backend/backend/src/services/session/session-binding.d.ts.map +1 -1
  33. package/dist/backend/backend/src/services/session/session-binding.js +32 -4
  34. package/dist/backend/backend/src/services/session/session-binding.js.map +1 -1
  35. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
  36. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
  37. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
  38. package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.d.ts +27 -2
  39. package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.d.ts.map +1 -1
  40. package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.js +105 -8
  41. package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.js.map +1 -1
  42. package/dist/backend/backend/src/services/wiki/wiki-log-entries.d.ts +7 -0
  43. package/dist/backend/backend/src/services/wiki/wiki-log-entries.d.ts.map +1 -1
  44. package/dist/backend/backend/src/services/wiki/wiki-log-entries.js +10 -0
  45. package/dist/backend/backend/src/services/wiki/wiki-log-entries.js.map +1 -1
  46. package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.d.ts +24 -0
  47. package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.js +75 -9
  49. package/dist/backend/backend/src/services/wiki/wiki-log-rotation.service.js.map +1 -1
  50. package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.d.ts +19 -0
  51. package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.js +23 -2
  53. package/dist/backend/backend/src/services/wiki/wiki-maintenance.service.js.map +1 -1
  54. package/dist/backend/backend/src/services/wiki/work-record-transcript.d.ts +64 -0
  55. package/dist/backend/backend/src/services/wiki/work-record-transcript.d.ts.map +1 -0
  56. package/dist/backend/backend/src/services/wiki/work-record-transcript.js +194 -0
  57. package/dist/backend/backend/src/services/wiki/work-record-transcript.js.map +1 -0
  58. package/dist/backend/backend/src/services/wiki/work-record.d.ts +239 -0
  59. package/dist/backend/backend/src/services/wiki/work-record.d.ts.map +1 -0
  60. package/dist/backend/backend/src/services/wiki/work-record.js +631 -0
  61. package/dist/backend/backend/src/services/wiki/work-record.js.map +1 -0
  62. package/dist/backend/backend/src/services/wiki/work-record.service.d.ts +231 -0
  63. package/dist/backend/backend/src/services/wiki/work-record.service.d.ts.map +1 -0
  64. package/dist/backend/backend/src/services/wiki/work-record.service.js +542 -0
  65. package/dist/backend/backend/src/services/wiki/work-record.service.js.map +1 -0
  66. package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts +92 -8
  67. package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/worktree/low-disk-guard.js +98 -14
  69. package/dist/backend/backend/src/services/worktree/low-disk-guard.js.map +1 -1
  70. package/dist/backend/backend/src/services/worktree/tmp-janitor.d.ts +138 -0
  71. package/dist/backend/backend/src/services/worktree/tmp-janitor.d.ts.map +1 -0
  72. package/dist/backend/backend/src/services/worktree/tmp-janitor.js +199 -0
  73. package/dist/backend/backend/src/services/worktree/tmp-janitor.js.map +1 -0
  74. package/dist/backend/backend/src/services/worktree/transcript-janitor.d.ts +154 -0
  75. package/dist/backend/backend/src/services/worktree/transcript-janitor.d.ts.map +1 -0
  76. package/dist/backend/backend/src/services/worktree/transcript-janitor.js +357 -0
  77. package/dist/backend/backend/src/services/worktree/transcript-janitor.js.map +1 -0
  78. package/dist/backend/backend/src/services/worktree/workitem-worktree.service.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/worktree/workitem-worktree.service.js +7 -2
  80. package/dist/backend/backend/src/services/worktree/workitem-worktree.service.js.map +1 -1
  81. package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts +16 -4
  82. package/dist/backend/backend/src/services/worktree/worktree-dirty.d.ts.map +1 -1
  83. package/dist/backend/backend/src/services/worktree/worktree-dirty.js +21 -5
  84. package/dist/backend/backend/src/services/worktree/worktree-dirty.js.map +1 -1
  85. package/dist/backend/backend/src/services/worktree/worktree-git.d.ts +18 -0
  86. package/dist/backend/backend/src/services/worktree/worktree-git.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/worktree/worktree-git.js +60 -0
  88. package/dist/backend/backend/src/services/worktree/worktree-git.js.map +1 -1
  89. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +164 -25
  90. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
  91. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +494 -55
  92. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
  93. package/dist/backend/backend/src/services/worktree/worktree-tiers.d.ts +186 -0
  94. package/dist/backend/backend/src/services/worktree/worktree-tiers.d.ts.map +1 -0
  95. package/dist/backend/backend/src/services/worktree/worktree-tiers.js +279 -0
  96. package/dist/backend/backend/src/services/worktree/worktree-tiers.js.map +1 -0
  97. package/dist/backend/build-info.json +2 -2
  98. package/dist/cli/backend/src/constants.d.ts +189 -2
  99. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  100. package/dist/cli/backend/src/constants.js +200 -3
  101. package/dist/cli/backend/src/constants.js.map +1 -1
  102. package/dist/cli/backend/src/services/agent/boot-pressure.d.ts +147 -0
  103. package/dist/cli/backend/src/services/agent/boot-pressure.d.ts.map +1 -0
  104. package/dist/cli/backend/src/services/agent/boot-pressure.js +212 -0
  105. package/dist/cli/backend/src/services/agent/boot-pressure.js.map +1 -0
  106. package/dist/cli/backend/src/services/agent/restore-queue.d.ts +50 -1
  107. package/dist/cli/backend/src/services/agent/restore-queue.d.ts.map +1 -1
  108. package/dist/cli/backend/src/services/agent/restore-queue.js +100 -5
  109. package/dist/cli/backend/src/services/agent/restore-queue.js.map +1 -1
  110. package/dist/cli/backend/src/services/memory/startup-briefing.service.d.ts +10 -0
  111. package/dist/cli/backend/src/services/memory/startup-briefing.service.d.ts.map +1 -1
  112. package/dist/cli/backend/src/services/memory/startup-briefing.service.js +65 -22
  113. package/dist/cli/backend/src/services/memory/startup-briefing.service.js.map +1 -1
  114. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
  115. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
  116. package/dist/cli/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
  117. package/dist/cli/backend/src/services/wiki/wiki-log-entries.d.ts +7 -0
  118. package/dist/cli/backend/src/services/wiki/wiki-log-entries.d.ts.map +1 -1
  119. package/dist/cli/backend/src/services/wiki/wiki-log-entries.js +10 -0
  120. package/dist/cli/backend/src/services/wiki/wiki-log-entries.js.map +1 -1
  121. package/dist/cli/backend/src/services/wiki/work-record-transcript.d.ts +64 -0
  122. package/dist/cli/backend/src/services/wiki/work-record-transcript.d.ts.map +1 -0
  123. package/dist/cli/backend/src/services/wiki/work-record-transcript.js +194 -0
  124. package/dist/cli/backend/src/services/wiki/work-record-transcript.js.map +1 -0
  125. package/dist/cli/backend/src/services/wiki/work-record.d.ts +239 -0
  126. package/dist/cli/backend/src/services/wiki/work-record.d.ts.map +1 -0
  127. package/dist/cli/backend/src/services/wiki/work-record.js +631 -0
  128. package/dist/cli/backend/src/services/wiki/work-record.js.map +1 -0
  129. package/dist/cli/backend/src/services/worktree/low-disk-guard.d.ts +92 -8
  130. package/dist/cli/backend/src/services/worktree/low-disk-guard.d.ts.map +1 -1
  131. package/dist/cli/backend/src/services/worktree/low-disk-guard.js +98 -14
  132. package/dist/cli/backend/src/services/worktree/low-disk-guard.js.map +1 -1
  133. package/dist/cli/backend/src/services/worktree/worktree-git.d.ts +18 -0
  134. package/dist/cli/backend/src/services/worktree/worktree-git.d.ts.map +1 -1
  135. package/dist/cli/backend/src/services/worktree/worktree-git.js +60 -0
  136. package/dist/cli/backend/src/services/worktree/worktree-git.js.map +1 -1
  137. package/package.json +1 -1
@@ -57,16 +57,36 @@
57
57
  * no submodules, it is retried once with `--force`. Then the local branch is
58
58
  * deleted (never a protected name), and `git worktree prune` runs per repo.
59
59
  *
60
- * ## Low disk: kept worktrees
61
- * Under low disk, a worktree kept as `dirty` or `not-merged` and idle for
62
- * 24 h+ still loses its `node_modules` / `.next` directories (reversible:
63
- * reinstall / rebuild); the verdict carries `reclaimed` and the summary
64
- * `reclaimedBytes`.
60
+ * ## Age tiers for kept worktrees (crewly#1354)
61
+ * A worktree kept as `dirty` or `not-merged` is not kept as it is forever.
62
+ * Untouched (shallow tree mtime, git dir, newest commit) for 3 days — 1 day
63
+ * under low disk or once its WorkItem finished — it loses its regenerable dirs
64
+ * (`node_modules`, `.next`, `dist`, `.turbo`, `.cache`; never a dir git
65
+ * tracks anything under); the verdict carries `reclaimed` / `tier`, the lead
66
+ * is told once. Untouched for 14 days it is saved as a patch under
67
+ * `<CREWLY_HOME>/worktree-archive/<repo>/<name>-<date>.patch` (tracked diff +
68
+ * untracked files, size-capped), its branch is kept, the worktree is removed
69
+ * and the lead is told once. See worktree-tiers.ts.
65
70
  *
66
71
  * ## Work completion
67
- * A WorkItem reaching done / verified calls `requestTidy()`: one debounced pass
68
- * (2 min) with a 10 min idle threshold, so a finished worktree is removed as
69
- * soon as its change has landed, not at the next 30-minute tick.
72
+ * A WorkItem reaching a terminal state calls `requestTidy(workItemId)`: one
73
+ * debounced pass (2 min) with a 10 min idle threshold, so a finished worktree
74
+ * is removed as soon as its change has landed, not at the next 30-minute
75
+ * tick; the worktree recorded on the WorkItem (`metadata.worktree`) is noted
76
+ * as finished, which drops its strip tier to 1 day.
77
+ *
78
+ * ## Transcripts, tmp and logs (crewly#1354)
79
+ * Once a day the pass prunes Claude Code transcripts of conversations Crewly
80
+ * launched that are untouched for 14 days (transcript-janitor.ts; the owner's
81
+ * own conversations are never touched). Every 6 hours it removes stale entries
82
+ * of `<CREWLY_HOME>/tmp` and caps `logs/archive` (300 MB) and `logs/sessions`
83
+ * (100 MB) (tmp-janitor.ts).
84
+ *
85
+ * ## Arming
86
+ * Every delete added in crewly#1354 (strip — low disk included — archive,
87
+ * transcripts, tmp, logs) is dry-run only until one day after the first real
88
+ * pass that ran this code: that pass logs what it would do; the next day it
89
+ * does it. State: `retentionArmedAt` in disk-janitor-state.json.
70
90
  *
71
91
  * ## Scratch sweep
72
92
  * After the worktrees, stale Claude Code session dirs
@@ -76,12 +96,13 @@
76
96
  *
77
97
  * ## Low-disk guard
78
98
  * Every 10 minutes the free space of the volume holding CREWLY_HOME is read.
79
- * Below 15 GB a pass runs at once (at most one per 30 minutes) with every
80
- * idle threshold halved (never below 2 h); every pass started while space is
81
- * low uses the halved thresholds. If space is still below 15 GB the owner is
82
- * told, at most once per 24 h, with the free space and the 5 biggest items
83
- * the janitor left and why; below 5 GB the notice is urgent and repeats at
84
- * most every 6 h.
99
+ * Below max(15 GB, 8 % of the disk) a pass runs at once (at most one per 30
100
+ * minutes) with every idle threshold halved (never below 2 h); every pass
101
+ * started while space is low uses the halved thresholds. If space is still
102
+ * low the owner is told, at most once per 24 h, with the free space, what was
103
+ * freed by kind, the 5 biggest items the janitor left and why, and that the
104
+ * owner's own files are never touched; below max(5 GB, 3 %) the notice is
105
+ * urgent and repeats at most every 6 h.
85
106
  *
86
107
  * ## Unmerged work
87
108
  * A worktree kept as `not-merged` whose newest commit is older than 24 h is
@@ -109,19 +130,22 @@
109
130
  import * as fs from 'fs';
110
131
  import * as os from 'os';
111
132
  import * as path from 'path';
112
- import { ORCHESTRATOR_SESSION_NAME, WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
133
+ import { LOG_ROTATION_CONSTANTS, ORCHESTRATOR_SESSION_NAME, WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
113
134
  import { LoggerService } from '../core/logger.service.js';
114
135
  import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
115
136
  import { safeReadJson, modifyJsonFile } from '../../utils/file-io.utils.js';
116
- import { buildLowDiskNotice, diskLevel, idleThreshold, noticeToSend, readFreeBytes, recordNotice, } from './low-disk-guard.js';
137
+ import { buildLowDiskNotice, criticalDiskThresholdBytes, diskLevel, idleThreshold, lowDiskThresholdBytes, noticeToSend, readDiskSpace, readFreeBytes, recordNotice, retentionArmed, } from './low-disk-guard.js';
117
138
  import { defaultScratchRoots, sweepScratch } from './scratch-janitor.js';
118
139
  import { reclaimDocker } from './docker-reclaim.js';
119
140
  import { RunawayFileWatcher, runawayWatchRoots } from './runaway-file-watcher.js';
120
141
  import { getTraceStore } from '../trace/trace-store.js';
121
- import { canonicalPath, diskUsageBytes, discoverLinkedWorktreeRepos, formatBytes, isJanitorDisabled, isPathInside, latestWorktreeMtimeMs, listProcessCwds, parsePorcelainWorktrees, runCommand, } from './worktree-janitor.git.js';
142
+ import { canonicalPath, diskUsageBytes, discoverLinkedWorktreeRepos, formatBytes, isJanitorDisabled, isPathInside, latestTreeMtimeMs, latestWorktreeMtimeMs, listProcessCwds, parsePorcelainWorktrees, runCommand, } from './worktree-janitor.git.js';
122
143
  import { buildUnmergedNotice, defaultResolveLeadSessions, pruneUnmergedState, shouldNotifyUnmerged, } from './worktree-unmerged-notice.js';
123
144
  import { contentLandedSignal, isNonGithubRemote } from './worktree-landed.js';
124
145
  import { dirtyStatusLines, reclaimRegenerableDirs } from './worktree-dirty.js';
146
+ import { archiveKeepRef, archivePatchPath, buildArchiveNotice, buildStripNotice, emptyTierState, normalizeTierState, pruneTierState, tierFor, tierIdleMs, writeArchivePatch, } from './worktree-tiers.js';
147
+ import { defaultClaudeHomes, readCrewlyRecords, sweepTranscripts, } from './transcript-janitor.js';
148
+ import { capDirectoryBytes, sweepTmp } from './tmp-janitor.js';
125
149
  /** Milliseconds per minute (for the `recent` detail text). */
126
150
  const MS_PER_MINUTE = 60 * 1000;
127
151
  /**
@@ -156,6 +180,7 @@ export class WorktreeJanitorService {
156
180
  runawayWatcher = null;
157
181
  runawayInFlight = false;
158
182
  memoryUnmergedState = {};
183
+ memoryTierState = emptyTierState();
159
184
  /**
160
185
  * @param options - Injectable dependencies; defaults use the live backend
161
186
  */
@@ -306,8 +331,16 @@ export class WorktreeJanitorService {
306
331
  */
307
332
  async diskStatus() {
308
333
  const p = this.diskPath();
309
- const freeBytes = await readFreeBytes(p, this.opts.statfs);
310
- return { path: p, freeBytes, level: freeBytes === null ? 'unknown' : diskLevel(freeBytes) };
334
+ const space = await readDiskSpace(p, this.opts.statfs);
335
+ const total = space?.totalBytes ?? null;
336
+ return {
337
+ path: p,
338
+ freeBytes: space?.freeBytes ?? null,
339
+ totalBytes: total,
340
+ lowBytes: lowDiskThresholdBytes(total),
341
+ criticalBytes: criticalDiskThresholdBytes(total),
342
+ level: space === null ? 'unknown' : diskLevel(space.freeBytes, total),
343
+ };
311
344
  }
312
345
  /**
313
346
  * Low-disk check: read free space; below LOW_DISK_BYTES run a pass now
@@ -350,8 +383,12 @@ export class WorktreeJanitorService {
350
383
  * Only worktrees that pass every safety rule are removed (landed, clean, nobody
351
384
  * inside) — unmerged work stays for the next pass. No-op when disabled. Never throws.
352
385
  */
353
- requestTidy() {
354
- if (this.isDisabled() || this.tidyTimer)
386
+ requestTidy(workItemId) {
387
+ if (this.isDisabled())
388
+ return;
389
+ if (workItemId)
390
+ void this.noteFinishedWorkItem(workItemId);
391
+ if (this.tidyTimer)
355
392
  return;
356
393
  this.tidyTimer = setTimeout(() => {
357
394
  this.tidyTimer = null;
@@ -377,6 +414,35 @@ export class WorktreeJanitorService {
377
414
  }, this.opts.tidyDelayMs ?? WORKTREE_JANITOR_CONSTANTS.COMPLETION_TIDY_DELAY_MS);
378
415
  this.tidyTimer.unref?.();
379
416
  }
417
+ /**
418
+ * Task-end hook: remember that the WorkItem working in `worktreePath`
419
+ * finished, so its strip tier needs 1 day untouched instead of 3. Never
420
+ * throws.
421
+ *
422
+ * @param worktreePath - The worktree (any path form; canonicalised)
423
+ */
424
+ async noteFinishedWorktree(worktreePath) {
425
+ try {
426
+ const state = await this.readTierState();
427
+ state.finished[canonicalPath(worktreePath)] = this.now();
428
+ await this.writeTierState(state);
429
+ }
430
+ catch (err) {
431
+ this.logger.warn('Could not record finished worktree (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
432
+ }
433
+ }
434
+ /** Resolve a WorkItem's worktree (`metadata.worktree.path`) and note it as finished. */
435
+ async noteFinishedWorkItem(workItemId) {
436
+ try {
437
+ const find = this.opts.findWorkItemWorktree ?? defaultFindWorkItemWorktree;
438
+ const p = await find(workItemId);
439
+ if (p)
440
+ await this.noteFinishedWorktree(p);
441
+ }
442
+ catch {
443
+ // no task pool — nothing to note
444
+ }
445
+ }
380
446
  /**
381
447
  * One real pass. Concurrent calls share the pass in flight. Never throws.
382
448
  * Honours the kill switch.
@@ -429,7 +495,14 @@ export class WorktreeJanitorService {
429
495
  const file = (await safeReadJson(p, null)) ?? {};
430
496
  // Memory wins when a save failed (full disk), so the owner is not re-told every check.
431
497
  const latest = (a, b) => a === undefined ? b : b === undefined ? a : Math.max(a, b);
432
- return { lastNoticeAt: latest(file.lastNoticeAt, mem.lastNoticeAt), lastUrgentAt: latest(file.lastUrgentAt, mem.lastUrgentAt) };
498
+ const earliest = (a, b) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
499
+ return {
500
+ lastNoticeAt: latest(file.lastNoticeAt, mem.lastNoticeAt),
501
+ lastUrgentAt: latest(file.lastUrgentAt, mem.lastUrgentAt),
502
+ retentionArmedAt: earliest(file.retentionArmedAt, mem.retentionArmedAt),
503
+ lastTranscriptSweepAt: latest(file.lastTranscriptSweepAt, mem.lastTranscriptSweepAt),
504
+ lastTmpSweepAt: latest(file.lastTmpSweepAt, mem.lastTmpSweepAt),
505
+ };
433
506
  }
434
507
  async writeNoticeState(state) {
435
508
  const p = this.statePath();
@@ -444,15 +517,47 @@ export class WorktreeJanitorService {
444
517
  this.logger.warn('Could not save disk janitor state', { error: err instanceof Error ? err.message : String(err) });
445
518
  }
446
519
  }
520
+ tierStatePath() {
521
+ if (this.opts.tierStatePath !== undefined)
522
+ return this.opts.tierStatePath;
523
+ return path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TIER_STATE_FILENAME);
524
+ }
525
+ async readTierState() {
526
+ const p = this.tierStatePath();
527
+ if (!p)
528
+ return this.memoryTierState;
529
+ const file = normalizeTierState(await safeReadJson(p, null));
530
+ // Memory wins when a save failed (full disk).
531
+ const mem = this.memoryTierState;
532
+ for (const key of ['stripped', 'archived', 'finished']) {
533
+ for (const [k, v] of Object.entries(mem[key]))
534
+ file[key][k] = Math.max(file[key][k] ?? 0, v);
535
+ }
536
+ return file;
537
+ }
538
+ async writeTierState(state) {
539
+ this.memoryTierState = state;
540
+ const p = this.tierStatePath();
541
+ if (!p)
542
+ return;
543
+ try {
544
+ await modifyJsonFile(p, null, () => state);
545
+ }
546
+ catch (err) {
547
+ this.logger.warn('Could not save worktree tier state', { error: err instanceof Error ? err.message : String(err) });
548
+ }
549
+ }
447
550
  async doCheckDisk() {
448
551
  const result = { freeBytesBefore: null, freeBytesAfter: null, ranPass: false, notified: null };
449
552
  try {
450
553
  if (this.isDisabled())
451
554
  return result;
452
- const before = await readFreeBytes(this.diskPath(), this.opts.statfs);
555
+ const space = await readDiskSpace(this.diskPath(), this.opts.statfs);
556
+ const before = space?.freeBytes ?? null;
557
+ const total = space?.totalBytes ?? null;
453
558
  result.freeBytesBefore = before;
454
559
  result.freeBytesAfter = before;
455
- if (before === null || diskLevel(before) === 'ok')
560
+ if (before === null || diskLevel(before, total) === 'ok')
456
561
  return result;
457
562
  let summary = this.lastSummary;
458
563
  const gapOk = this.lastPassAt === null || this.now() - this.lastPassAt >= WORKTREE_JANITOR_CONSTANTS.LOW_DISK_PASS_GAP_MS;
@@ -466,13 +571,14 @@ export class WorktreeJanitorService {
466
571
  if (after === null || !this.notifier)
467
572
  return result;
468
573
  const state = await this.readNoticeState();
469
- const kind = noticeToSend(diskLevel(after), state, this.now());
574
+ const kind = noticeToSend(diskLevel(after, total), state, this.now());
470
575
  if (!kind)
471
576
  return result;
472
577
  const items = summary ? await this.keptItems(summary) : [];
473
578
  // What the volume actually gained covers worktrees and scratch alike.
474
579
  const freed = result.ranPass ? Math.max(0, after - before) : 0;
475
- const notice = buildLowDiskNotice({ freeBytes: after, urgent: kind === 'urgent', freedBytes: freed, items });
580
+ const breakdown = result.ranPass && summary ? summary.freed : undefined;
581
+ const notice = buildLowDiskNotice({ freeBytes: after, urgent: kind === 'urgent', freedBytes: freed, items, freed: breakdown, totalBytes: total });
476
582
  const delivered = await this.notifier({ ...notice, urgent: kind === 'urgent' }).catch(() => false);
477
583
  if (delivered) {
478
584
  await this.writeNoticeState(recordNotice(state, kind, this.now()));
@@ -543,12 +649,20 @@ export class WorktreeJanitorService {
543
649
  wouldRemove: 0,
544
650
  kept: 0,
545
651
  reclaimedBytes: 0,
652
+ archived: 0,
653
+ archivedBytes: 0,
654
+ retentionArmed: false,
655
+ totalBytes: null,
546
656
  keptReasons: {},
547
657
  worktrees: [],
548
658
  lowDisk: false,
549
659
  freeBytes: null,
550
660
  scratch: null,
551
661
  traces: null,
662
+ transcripts: null,
663
+ tmp: null,
664
+ logs: null,
665
+ freed: { worktrees: 0, stripped: 0, archived: 0, transcripts: 0, tmp: 0 },
552
666
  };
553
667
  if (!dryRun && this.isDisabled()) {
554
668
  summary.disabled = true;
@@ -556,31 +670,42 @@ export class WorktreeJanitorService {
556
670
  }
557
671
  try {
558
672
  this.ghUsable = undefined;
559
- summary.freeBytes = await readFreeBytes(this.diskPath(), this.opts.statfs);
560
- summary.lowDisk = summary.freeBytes !== null && diskLevel(summary.freeBytes) !== 'ok';
673
+ const space = await readDiskSpace(this.diskPath(), this.opts.statfs);
674
+ summary.freeBytes = space?.freeBytes ?? null;
675
+ summary.totalBytes = space?.totalBytes ?? null;
676
+ summary.lowDisk = summary.freeBytes !== null && diskLevel(summary.freeBytes, summary.totalBytes) !== 'ok';
677
+ // Arming: the first real pass with the crewly#1354 deletes only reports them.
678
+ let noticeState = await this.readNoticeState();
679
+ if (!dryRun && noticeState.retentionArmedAt === undefined) {
680
+ noticeState = { ...noticeState, retentionArmedAt: this.now() };
681
+ await this.writeNoticeState(noticeState);
682
+ this.logger.info('Disk janitor: first pass with worktree tiers and transcript/tmp/log retention — reporting only today, deleting from tomorrow');
683
+ }
684
+ summary.retentionArmed = this.opts.armed ?? retentionArmed(noticeState, this.now());
561
685
  const repos = await this.resolveRepos();
562
686
  summary.repos = repos.map((r) => r.main);
563
687
  const busy = await this.safeBusyCwds();
688
+ let tierState = await this.readTierState();
689
+ const existing = new Set();
564
690
  for (const repo of repos) {
565
691
  let removedHere = 0;
566
692
  const unmerged = [];
567
693
  for (const entry of repo.entries) {
694
+ existing.add(canonicalPath(entry.path));
568
695
  const verdict = await this.evaluate(repo, entry, busy, summary.lowDisk, idleOverrideMs);
569
- if (summary.lowDisk && verdict.decision === 'keep' && (verdict.reason === 'dirty' || verdict.reason === 'not-merged')) {
570
- const rec = await this.reclaimKept(entry.path, verdict.idleMs, dryRun);
571
- if (rec) {
572
- verdict.reclaimed = rec;
573
- summary.reclaimedBytes += rec.freedBytes;
574
- }
575
- }
576
696
  const outcome = { ...verdict, removed: false, forced: false, branchDeleted: false };
697
+ if (verdict.decision === 'keep') {
698
+ tierState = await this.applyTier(repo, entry, outcome, busy, tierState, summary, dryRun);
699
+ if (outcome.removed)
700
+ removedHere++;
701
+ }
577
702
  if (verdict.decision === 'remove' && !dryRun) {
578
703
  await this.remove(repo, outcome);
579
704
  if (outcome.removed)
580
705
  removedHere++;
581
706
  }
582
707
  summary.worktrees.push(outcome);
583
- if (!dryRun && verdict.decision === 'keep' && verdict.reason === 'not-merged')
708
+ if (!dryRun && !outcome.removed && verdict.decision === 'keep' && verdict.reason === 'not-merged')
584
709
  unmerged.push(outcome);
585
710
  if (outcome.removed)
586
711
  summary.removed++;
@@ -610,7 +735,11 @@ export class WorktreeJanitorService {
610
735
  git: (args) => this.git(args),
611
736
  sizeOf: this.opts.sizeOf,
612
737
  });
738
+ summary.freed.worktrees += summary.scratch?.freedBytes ?? 0;
739
+ if (!dryRun)
740
+ await this.writeTierState(pruneTierState(tierState, this.now(), existing));
613
741
  summary.traces = await getTraceStore().enforceTotalCap({ dryRun });
742
+ await this.sweepRetention(summary, busy, noticeState, dryRun);
614
743
  }
615
744
  catch (err) {
616
745
  this.logger.warn('Worktree janitor pass failed', { error: err instanceof Error ? err.message : String(err) });
@@ -621,7 +750,12 @@ export class WorktreeJanitorService {
621
750
  .sort((a, b) => b[1] - a[1])
622
751
  .map(([k, v]) => `${k}: ${v}`)
623
752
  .join(', ');
624
- this.logger.info(`Worktree janitor: removed ${summary.removed}, kept ${summary.kept}${reasons ? ` (${reasons})` : ''}${summary.reclaimedBytes > 0 ? `, reclaimed ${formatBytes(summary.reclaimedBytes)} of node_modules/.next from kept worktrees` : ''} across ${summary.repos.length} repo(s) in ${summary.durationMs}ms`, { removed: summary.worktrees.filter((w) => w.removed).map((w) => w.path), lowDisk: summary.lowDisk });
753
+ const would = summary.worktrees.filter((w) => w.tier?.action === 'would');
754
+ const wouldStrip = would.filter((w) => w.tier?.tier === 'strip');
755
+ const wouldText = would.length > 0
756
+ ? `; not yet armed: would strip ${wouldStrip.length} (${formatBytes(wouldStrip.reduce((n, w) => n + (w.tier?.freedBytes ?? 0), 0))}), archive ${would.length - wouldStrip.length}`
757
+ : '';
758
+ this.logger.info(`Worktree janitor: removed ${summary.removed}${summary.archived > 0 ? ` (${summary.archived} archived as patches, ${formatBytes(summary.archivedBytes)})` : ''}, kept ${summary.kept}${reasons ? ` (${reasons})` : ''}${summary.reclaimedBytes > 0 ? `, stripped ${formatBytes(summary.reclaimedBytes)} of node_modules/build output from idle kept worktrees` : ''}${wouldText} across ${summary.repos.length} repo(s) in ${summary.durationMs}ms`, { removed: summary.worktrees.filter((w) => w.removed).map((w) => w.path), lowDisk: summary.lowDisk, retentionArmed: summary.retentionArmed });
625
759
  const sc = summary.scratch;
626
760
  if (sc) {
627
761
  const scReasons = Object.entries(sc.keptReasons)
@@ -634,9 +768,238 @@ export class WorktreeJanitorService {
634
768
  if (tr && tr.files > 0) {
635
769
  this.logger.info(`Trace janitor: traces folder ${formatBytes(tr.bytes)} in ${tr.files} trace(s), cap ${formatBytes(tr.capBytes)}${tr.prunedTraces ? `, pruned ${tr.prunedTraces} oldest (${formatBytes(tr.freedBytes)})` : ''}`, { ...tr });
636
770
  }
771
+ this.logRetention(summary);
637
772
  }
638
773
  return summary;
639
774
  }
775
+ /** One log line each for the transcript, tmp and log sweeps of a real pass. */
776
+ logRetention(summary) {
777
+ const days = Math.round(WORKTREE_JANITOR_CONSTANTS.TRANSCRIPT_MAX_AGE_MS / 86_400_000);
778
+ const armed = summary.retentionArmed;
779
+ const ts = summary.transcripts;
780
+ if (ts) {
781
+ const kept = Object.entries(ts.keptReasons).map(([k, v]) => `${k}: ${v}`).join(', ') || 'none';
782
+ this.logger.info(armed
783
+ ? `Transcript janitor: pruned ${ts.pruned} agent transcript(s) older than ${days} days, freed ${formatBytes(ts.freedBytes)}; scanned ${ts.scanned} in ${ts.homes.length} home(s), kept ${kept}`
784
+ : `Transcript janitor (dry run, not yet armed): would prune ${ts.wouldPrune} agent transcript(s) older than ${days} days, ${formatBytes(ts.wouldFreeBytes)}; scanned ${ts.scanned} in ${ts.homes.length} home(s), kept ${kept}`, { homes: ts.homes, pruned: ts.transcripts.filter((t) => t.removed).map((t) => t.path) });
785
+ }
786
+ const tm = summary.tmp;
787
+ if (tm) {
788
+ const kept = Object.entries(tm.keptReasons).map(([k, v]) => `${k}: ${v}`).join(', ');
789
+ this.logger.info(armed
790
+ ? `Tmp janitor: removed ${tm.removed} entr${tm.removed === 1 ? 'y' : 'ies'} of ${tm.root}, freed ${formatBytes(tm.freedBytes)}, kept ${tm.kept}${kept ? ` (${kept})` : ''}`
791
+ : `Tmp janitor (dry run, not yet armed): would remove ${tm.wouldRemove} entr${tm.wouldRemove === 1 ? 'y' : 'ies'} of ${tm.root}, ${formatBytes(tm.wouldFreeBytes)}, kept ${tm.kept}${kept ? ` (${kept})` : ''}`, { removed: tm.entries.filter((e) => e.removed).map((e) => e.path) });
792
+ }
793
+ const lg = summary.logs;
794
+ if (lg) {
795
+ for (const c of [lg.archive, lg.sessions]) {
796
+ if (c.deleted.length === 0 && c.wouldDelete.length === 0 && c.overCapBytes === 0)
797
+ continue;
798
+ this.logger.info(armed
799
+ ? `Log janitor: ${c.dir} was ${formatBytes(c.totalBytes)} (cap ${formatBytes(c.capBytes)}), deleted ${c.deleted.length} oldest file(s), freed ${formatBytes(c.freedBytes)}${c.overCapBytes > 0 ? `, still ${formatBytes(c.overCapBytes)} over the cap (live or fresh logs)` : ''}`
800
+ : `Log janitor (dry run, not yet armed): ${c.dir} is ${formatBytes(c.totalBytes)} (cap ${formatBytes(c.capBytes)}), would delete ${c.wouldDelete.length} oldest file(s), ${formatBytes(c.wouldFreeBytes)}`);
801
+ }
802
+ }
803
+ }
804
+ /**
805
+ * Age tiers for a kept worktree (crewly#1354): strip its regenerable dirs
806
+ * after 3 days untouched (1 day under low disk / after a finished WorkItem),
807
+ * archive it as a patch and remove it after 14 days. Managed per-WorkItem
808
+ * worktrees are only stripped (keeping their shared symlinks), never
809
+ * archived — their own service owns them. Never throws.
810
+ *
811
+ * @returns The (possibly updated) tier state
812
+ */
813
+ async applyTier(repo, entry, outcome, busy, state, summary, dryRun) {
814
+ const managed = outcome.reason === 'managed-by-workitem-worktrees';
815
+ if (!managed && outcome.reason !== 'dirty' && outcome.reason !== 'not-merged')
816
+ return state;
817
+ try {
818
+ const key = canonicalPath(entry.path);
819
+ const finishedAt = state.finished[key];
820
+ if (managed) {
821
+ // Only a finished WorkItem's managed worktree is touched, and only to strip it.
822
+ if (finishedAt === undefined)
823
+ return state;
824
+ if (busy === null || busy.some((b) => isPathInside(b.cwd, entry.path)))
825
+ return state;
826
+ }
827
+ const gitDirR = await this.git(['-C', entry.path, 'rev-parse', '--absolute-git-dir']);
828
+ const gitDir = gitDirR.code === 0 ? gitDirR.stdout.trim() : null;
829
+ const log = await this.git(['-C', entry.path, 'log', '-1', '--format=%ct', 'HEAD']);
830
+ const newestCommitMs = log.code === 0 && /^\d+$/.test(log.stdout.trim()) ? Number(log.stdout.trim()) * 1000 : null;
831
+ const idleMs = tierIdleMs({
832
+ now: this.now(),
833
+ treeMtimeMs: await latestTreeMtimeMs(entry.path, WORKTREE_JANITOR_CONSTANTS.TIER_MTIME_DEPTH),
834
+ gitMtimeMs: latestWorktreeMtimeMs(entry.path, gitDir),
835
+ newestCommitMs,
836
+ });
837
+ let tier = tierFor({ idleMs, lowDisk: summary.lowDisk, finishedAt });
838
+ if (tier === null)
839
+ return state;
840
+ if (managed && tier === 'archive')
841
+ tier = 'strip';
842
+ const idle = idleMs ?? 0;
843
+ if (tier === 'strip') {
844
+ const act = !dryRun && summary.retentionArmed;
845
+ const rec = await reclaimRegenerableDirs(entry.path, this.opts.sizeOf ?? diskUsageBytes, !act, {
846
+ isTracked: (dir) => this.isTracked(entry.path, dir),
847
+ keepSymlinks: managed,
848
+ });
849
+ if (rec.dirs.length === 0)
850
+ return state;
851
+ // `reclaimed` keeps its pre-#1354 meaning (low-disk reclaim, done or planned); the tier carries the rest.
852
+ if (act || (summary.lowDisk && dryRun))
853
+ outcome.reclaimed = rec;
854
+ outcome.tier = { tier, idleMs: idle, action: act ? 'done' : 'would', dirs: rec.dirs, freedBytes: rec.freedBytes };
855
+ if (act) {
856
+ summary.reclaimedBytes += rec.freedBytes;
857
+ summary.freed.stripped += rec.freedBytes;
858
+ if (state.stripped[key] === undefined) {
859
+ state = { ...state, stripped: { ...state.stripped, [key]: this.now() } };
860
+ await this.tellLead(repo, entry.path, buildStripNotice({ path: entry.path, reason: outcome.reason, idleMs: idle, dirs: rec.dirs, freedBytes: rec.freedBytes }));
861
+ }
862
+ }
863
+ return state;
864
+ }
865
+ const act = !dryRun && summary.retentionArmed;
866
+ const bytes = (await (this.opts.sizeOf ?? diskUsageBytes)(entry.path)) ?? 0;
867
+ if (!act) {
868
+ outcome.tier = { tier, idleMs: idle, action: 'would', freedBytes: bytes };
869
+ return state;
870
+ }
871
+ const defaultRef = await this.defaultRef(repo);
872
+ let commits = null;
873
+ if (entry.head && defaultRef) {
874
+ const c = await this.git(['-C', repo.main, 'rev-list', '--count', entry.head, `^${defaultRef}`]);
875
+ commits = c.code === 0 && /^\d+$/.test(c.stdout.trim()) ? Number(c.stdout.trim()) : null;
876
+ }
877
+ let keepRef = null;
878
+ if (!entry.branch && entry.head && (commits ?? 1) > 0) {
879
+ keepRef = archiveKeepRef(entry.path, this.now());
880
+ const u = await this.git(['-C', repo.main, 'update-ref', keepRef, entry.head]);
881
+ if (u.code !== 0)
882
+ keepRef = null;
883
+ }
884
+ const file = archivePatchPath(this.opts.archiveRoot ?? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.ARCHIVE_DIR), repo.main, entry.path, this.now());
885
+ let patch;
886
+ try {
887
+ patch = await writeArchivePatch((args, options) => this.git(args, options), { repoMain: repo.main, worktreePath: entry.path, branch: entry.branch, head: entry.head, defaultRef, commitsNotOnDefault: commits, keepRef }, file, this.now());
888
+ }
889
+ catch (err) {
890
+ outcome.tier = { tier, idleMs: idle, action: 'failed', freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
891
+ this.logger.warn('Worktree archive failed; worktree kept', { path: entry.path, error: outcome.tier.error });
892
+ return state;
893
+ }
894
+ const r = await this.git(['-C', repo.main, 'worktree', 'remove', '--force', entry.path]);
895
+ if (r.code !== 0) {
896
+ outcome.tier = { tier, idleMs: idle, action: 'failed', freedBytes: 0, patch: file, error: (r.stderr || 'git worktree remove failed').trim().slice(0, 300) };
897
+ this.logger.warn('Worktree archive: patch saved but removal failed; worktree kept', { path: entry.path, patch: file, error: outcome.tier.error });
898
+ return state;
899
+ }
900
+ outcome.removed = true;
901
+ outcome.forced = true;
902
+ outcome.decision = 'remove';
903
+ outcome.reason = 'archived';
904
+ outcome.detail = `untouched ${Math.floor(idle / 86_400_000)} days; patch ${file}`;
905
+ outcome.tier = { tier, idleMs: idle, action: 'done', freedBytes: bytes, patch: file };
906
+ summary.archived += 1;
907
+ summary.archivedBytes += bytes;
908
+ summary.freed.archived += bytes;
909
+ state = { ...state, archived: { ...state.archived, [key]: this.now() } };
910
+ await this.tellLead(repo, entry.path, buildArchiveNotice({ path: entry.path, branch: entry.branch, patch, idleMs: idle, commitsNotOnDefault: commits, keepRef }));
911
+ this.logger.info('Archived and removed an untouched worktree', { path: entry.path, patch: file, bytes, branch: entry.branch });
912
+ }
913
+ catch (err) {
914
+ this.logger.warn('Worktree tier failed (non-fatal)', { path: entry.path, error: err instanceof Error ? err.message : String(err) });
915
+ }
916
+ return state;
917
+ }
918
+ /** Whether git tracks anything under `dir` of the worktree (null = could not tell). */
919
+ async isTracked(worktreePath, dir) {
920
+ const rel = path.relative(worktreePath, dir);
921
+ const r = await this.git(['-C', worktreePath, 'ls-files', '--', rel]);
922
+ if (r.code !== 0)
923
+ return null;
924
+ return r.stdout.trim() !== '';
925
+ }
926
+ /** Tell the owning team's lead (else the orchestrator) one message. Never throws. */
927
+ async tellLead(repo, worktreePath, message) {
928
+ const notifier = this.agentNotifier;
929
+ if (!notifier)
930
+ return;
931
+ try {
932
+ const leads = await (this.opts.resolveLeadSessions ?? defaultResolveLeadSessions)(repo.main, worktreePath).catch(() => []);
933
+ for (const session of leads.length > 0 ? leads : [ORCHESTRATOR_SESSION_NAME])
934
+ await notifier(session, message).catch(() => false);
935
+ }
936
+ catch {
937
+ // best effort
938
+ }
939
+ }
940
+ /**
941
+ * Transcript, tmp and log retention (crewly#1354), each on its own
942
+ * cadence; all dry-run until armed. Never throws.
943
+ */
944
+ async sweepRetention(summary, busy, noticeState, dryRun) {
945
+ const now = this.now();
946
+ const effectiveDry = dryRun || !summary.retentionArmed;
947
+ const due = (last, interval) => this.opts.forceSweeps === true || last === undefined || now - last >= interval;
948
+ let stateChanged = false;
949
+ try {
950
+ // Under jest the real ~/.claude is never scanned unless a test asks for it.
951
+ const homes = this.opts.claudeHomes ?? ((this.opts.env ?? process.env).JEST_WORKER_ID ? [] : defaultClaudeHomes(getCrewlyHomePath()));
952
+ if (homes.length > 0 && due(noticeState.lastTranscriptSweepAt, WORKTREE_JANITOR_CONSTANTS.TRANSCRIPT_SWEEP_INTERVAL_MS)) {
953
+ const sources = await (this.opts.transcriptSources ?? (() => defaultTranscriptSources(now)))();
954
+ summary.transcripts = await sweepTranscripts({ claudeHomes: homes, sources, now, dryRun: effectiveDry, sizeOf: this.opts.sizeOf });
955
+ summary.freed.transcripts += summary.transcripts.freedBytes;
956
+ if (!effectiveDry) {
957
+ noticeState.lastTranscriptSweepAt = now;
958
+ stateChanged = true;
959
+ }
960
+ }
961
+ }
962
+ catch (err) {
963
+ this.logger.warn('Transcript sweep failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
964
+ }
965
+ try {
966
+ const tmpRoot = this.opts.tmpRoot === undefined ? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TMP_DIR) : this.opts.tmpRoot;
967
+ const logsDir = this.opts.logsDir === undefined ? path.join(getCrewlyHomePath(), LOG_ROTATION_CONSTANTS.LOGS_DIR) : this.opts.logsDir;
968
+ if ((tmpRoot || logsDir) && due(noticeState.lastTmpSweepAt, WORKTREE_JANITOR_CONSTANTS.TMP_SWEEP_INTERVAL_MS)) {
969
+ if (tmpRoot) {
970
+ summary.tmp = await sweepTmp({ root: tmpRoot, busy, now, dryRun: effectiveDry, sizeOf: this.opts.sizeOf });
971
+ summary.freed.tmp += summary.tmp.freedBytes;
972
+ }
973
+ if (logsDir) {
974
+ const live = new Set(await (this.opts.liveSessionNames ?? defaultLiveSessionNames)().catch(() => []));
975
+ const archive = await capDirectoryBytes({
976
+ dir: path.join(logsDir, LOG_ROTATION_CONSTANTS.ARCHIVE_DIR),
977
+ capBytes: WORKTREE_JANITOR_CONSTANTS.LOG_ARCHIVE_CAP_BYTES,
978
+ now,
979
+ dryRun: effectiveDry,
980
+ });
981
+ const sessions = await capDirectoryBytes({
982
+ dir: path.join(logsDir, LOG_ROTATION_CONSTANTS.SESSIONS_LOG_DIR),
983
+ capBytes: WORKTREE_JANITOR_CONSTANTS.LOG_SESSIONS_CAP_BYTES,
984
+ now,
985
+ dryRun: effectiveDry,
986
+ protect: (name) => live.has(name.replace(/\.log$/, '')),
987
+ });
988
+ summary.logs = { archive, sessions };
989
+ summary.freed.tmp += archive.freedBytes + sessions.freedBytes;
990
+ }
991
+ if (!effectiveDry) {
992
+ noticeState.lastTmpSweepAt = now;
993
+ stateChanged = true;
994
+ }
995
+ }
996
+ }
997
+ catch (err) {
998
+ this.logger.warn('Tmp/log sweep failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) });
999
+ }
1000
+ if (stateChanged)
1001
+ await this.writeNoticeState(noticeState);
1002
+ }
640
1003
  /**
641
1004
  * Tell the owning team's lead (else the orchestrator) about kept worktrees
642
1005
  * with unmerged work, once per worktree per interval. Never throws.
@@ -727,7 +1090,9 @@ export class WorktreeJanitorService {
727
1090
  const registered = await (this.opts.listRepoPaths ?? defaultListRepoPaths)().catch(() => []);
728
1091
  let discovered = [];
729
1092
  try {
730
- discovered = await discoverLinkedWorktreeRepos([...this.tmpRoots(), ...this.scratchRoots()]);
1093
+ // CREWLY_HOME/tmp too: agents put worktrees there, and the tmp sweep leaves git checkouts to these rules.
1094
+ const crewlyTmp = this.opts.tmpRoot === undefined ? path.join(getCrewlyHomePath(), WORKTREE_JANITOR_CONSTANTS.TMP_DIR) : this.opts.tmpRoot;
1095
+ discovered = await discoverLinkedWorktreeRepos([...this.tmpRoots(), ...this.scratchRoots(), ...(crewlyTmp ? [crewlyTmp] : [])]);
731
1096
  }
732
1097
  catch {
733
1098
  // discovery is best effort
@@ -840,23 +1205,6 @@ export class WorktreeJanitorService {
840
1205
  }
841
1206
  return keep('not-merged', defaultRef ? `not in ${defaultRef} (history or content), no merged PR` : 'no origin default branch, no merged PR', idleMs);
842
1207
  }
843
- /**
844
- * Low disk: delete the regenerable dirs (node_modules, .next) of a kept
845
- * worktree that has been idle for a day. Reversible; never touches
846
- * tracked or source files. Never throws.
847
- */
848
- async reclaimKept(worktreePath, idleMs, dryRun) {
849
- if (idleMs === undefined || idleMs < WORKTREE_JANITOR_CONSTANTS.RECLAIM_MIN_IDLE_MS)
850
- return null;
851
- try {
852
- const r = await reclaimRegenerableDirs(worktreePath, this.opts.sizeOf ?? diskUsageBytes, dryRun);
853
- return r.dirs.length > 0 ? r : null;
854
- }
855
- catch (err) {
856
- this.logger.warn('Reclaiming regenerable dirs failed (non-fatal)', { path: worktreePath, error: err instanceof Error ? err.message : String(err) });
857
- return null;
858
- }
859
- }
860
1208
  /** `origin/<default>` after one quiet fetch per repo per pass; null when there is no origin. */
861
1209
  async defaultRef(repo) {
862
1210
  if (repo.defaultRef !== undefined)
@@ -996,6 +1344,97 @@ export async function defaultListRepoPaths() {
996
1344
  }
997
1345
  return out;
998
1346
  }
1347
+ /**
1348
+ * Default WorkItem -> worktree lookup for the task-end hook: the worktree the
1349
+ * per-WorkItem worktree feature recorded on the item (`metadata.worktree.path`).
1350
+ *
1351
+ * @param workItemId - WorkItem id
1352
+ * @returns Worktree path, or null when the item has none
1353
+ */
1354
+ export async function defaultFindWorkItemWorktree(workItemId) {
1355
+ try {
1356
+ const { TaskPoolService } = await import('../task-pool/task-pool.service.js');
1357
+ const item = (await TaskPoolService.getInstance().getAllItems()).find((i) => i.id === workItemId);
1358
+ const wt = item?.metadata?.worktree;
1359
+ return typeof wt?.path === 'string' ? wt.path : null;
1360
+ }
1361
+ catch {
1362
+ return null;
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Default transcript sources: Crewly's records under CREWLY_HOME
1367
+ * (session-state, last-conversations, handover files), the live session
1368
+ * backend (names) and persistence (names + current conversation ids, kept),
1369
+ * and every team member's session name.
1370
+ *
1371
+ * @param now - Current time (ms)
1372
+ * @returns Known session names, recorded ids, ids to keep
1373
+ */
1374
+ export async function defaultTranscriptSources(now) {
1375
+ const sets = await readCrewlyRecords(getCrewlyHomePath(), now);
1376
+ try {
1377
+ const { getSessionBackendSync } = await import('../session/session-backend.factory.js');
1378
+ for (const name of getSessionBackendSync()?.listSessions() ?? [])
1379
+ sets.knownSessionNames.add(name);
1380
+ }
1381
+ catch {
1382
+ // no session backend
1383
+ }
1384
+ try {
1385
+ const { getSessionStatePersistence } = await import('../session/session-state-persistence.js');
1386
+ const persistence = getSessionStatePersistence();
1387
+ for (const name of persistence.getRegisteredSessions()) {
1388
+ sets.knownSessionNames.add(name);
1389
+ const id = persistence.getSessionId(name);
1390
+ if (id) {
1391
+ sets.keepIds.add(id.toLowerCase());
1392
+ sets.recordedIds.add(id.toLowerCase());
1393
+ }
1394
+ }
1395
+ }
1396
+ catch {
1397
+ // no persistence
1398
+ }
1399
+ try {
1400
+ const { StorageService } = await import('../core/storage.service.js');
1401
+ for (const team of await StorageService.getInstance().getTeams()) {
1402
+ for (const m of team.members ?? [])
1403
+ if (m.sessionName)
1404
+ sets.knownSessionNames.add(m.sessionName);
1405
+ }
1406
+ }
1407
+ catch {
1408
+ // no teams store
1409
+ }
1410
+ return sets;
1411
+ }
1412
+ /**
1413
+ * Default live-session names (their logs are never deleted by the cap): the
1414
+ * session backend's sessions plus every persisted session.
1415
+ *
1416
+ * @returns Session names
1417
+ */
1418
+ export async function defaultLiveSessionNames() {
1419
+ const out = new Set();
1420
+ try {
1421
+ const { getSessionBackendSync } = await import('../session/session-backend.factory.js');
1422
+ for (const name of getSessionBackendSync()?.listSessions() ?? [])
1423
+ out.add(name);
1424
+ }
1425
+ catch {
1426
+ // no session backend
1427
+ }
1428
+ try {
1429
+ const { getSessionStatePersistence } = await import('../session/session-state-persistence.js');
1430
+ for (const name of getSessionStatePersistence().getRegisteredSessions())
1431
+ out.add(name);
1432
+ }
1433
+ catch {
1434
+ // no persistence
1435
+ }
1436
+ return [...out];
1437
+ }
999
1438
  /**
1000
1439
  * Default busy-cwd probe: the start directory of every live Crewly session,
1001
1440
  * plus the cwd of every process of this user (which covers the PTY shells,