diff --git a/App.xaml.cs b/App.xaml.cs
index 1dbc5c7..35b18fe 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -26,6 +26,10 @@ public partial class App : Application
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin");
+
+ // TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted.
+ var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath());
+ if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}");
// Attribution (2026-09-04): every take must be traceable to the exact binary
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
// the per-build GUID is forensic only and lands here.
diff --git a/Services/AlertClipCleanup.cs b/Services/AlertClipCleanup.cs
new file mode 100644
index 0000000..37f312a
--- /dev/null
+++ b/Services/AlertClipCleanup.cs
@@ -0,0 +1,60 @@
+using System;
+using System.IO;
+
+namespace ytLive.Services;
+
+///
+/// TASK 55·2: the default alert clip is materialised into %TEMP% so ffmpeg can read it as a file.
+/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
+///
+/// Two touches, deliberately simple: delete this session's file on close, and sweep anything the
+/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
+/// created are touched — the prefix is the contract, and the sweep never deletes a directory.
+///
+public static class AlertClipCleanup
+{
+ public const string Prefix = "ytLive-alert-";
+
+ /// Deletes stale alert clips left in . Returns how many went.
+ /// Missing directory is not an error.
+ public static int Sweep(string directory)
+ {
+ try
+ {
+ if (!Directory.Exists(directory)) return 0;
+ var removed = 0;
+ foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
+ {
+ if (TryDelete(path)) removed++;
+ }
+ return removed;
+ }
+ catch (Exception ex)
+ {
+ Helpers.AppLog.Write(ex, "Alert clip sweep failed");
+ return 0;
+ }
+ }
+
+ /// Deletes one clip if it exists. Safe on null/blank and on a locked file.
+ public static bool Delete(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return false;
+ return TryDelete(path);
+ }
+
+ private static bool TryDelete(string path)
+ {
+ try
+ {
+ if (!File.Exists(path)) return false;
+ File.Delete(path);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
+ return false;
+ }
+ }
+}
diff --git a/Services/DiskSpaceGuard.cs b/Services/DiskSpaceGuard.cs
new file mode 100644
index 0000000..e9b8a19
--- /dev/null
+++ b/Services/DiskSpaceGuard.cs
@@ -0,0 +1,51 @@
+using System;
+
+namespace ytLive.Services;
+
+///
+/// TASK 55·3: a recording that dies of a full disk is a trust breach, not a support ticket. The guard is
+/// a pure threshold policy so the numbers are visible in one place and testable without a drive.
+///
+/// Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start
+/// under 1 GB keeps the creator from beginning something that cannot finish; a warning under
+/// 5 GB says "you have about eighty minutes" while there is still time to act; and one mid-show
+/// warning at 2 GB is the last useful moment before the encoder fails for a reason it will not
+/// explain.
+///
+public static class DiskSpaceGuard
+{
+ public const long BlockStartBytes = 1L * 1024 * 1024 * 1024;
+ public const long WarnStartBytes = 5L * 1024 * 1024 * 1024;
+ public const long WarnDuringBytes = 2L * 1024 * 1024 * 1024;
+
+ public enum StartDecision
+ {
+ Ok,
+ Warn,
+ Block,
+ }
+
+ public static StartDecision ForStart(long freeBytes)
+ => freeBytes < BlockStartBytes ? StartDecision.Block
+ : freeBytes < WarnStartBytes ? StartDecision.Warn
+ : StartDecision.Ok;
+
+ /// One warning per session, once the drive falls below the mid-recording threshold.
+ public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes;
+
+ /// Free bytes on the drive that holds , or null when it cannot be
+ /// read (a removable drive that just went away must not block a recording that is already running).
+ public static long? FreeBytes(string folder)
+ {
+ try
+ {
+ var root = System.IO.Path.GetPathRoot(System.IO.Path.GetFullPath(folder));
+ if (string.IsNullOrEmpty(root)) return null;
+ return new System.IO.DriveInfo(root).AvailableFreeSpace;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/TASKS.md b/TASKS.md
index 5f3f759..7986a80 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -9,7 +9,7 @@
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
-`37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
+`37 → 40·A/C/D → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
@@ -22,7 +22,7 @@ output.
| 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
| 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) |
| 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
-| 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
+| 55 | ✅ | **Disk hygiene** — log rotation (5 MB, one roll), temp alert clips deleted on close + swept at startup, low-disk guard (block under 1 GB, warn under 5 GB at start, one warning under 2 GB mid-show) — done 2026-11-10 | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| — | ☐ | **Move transition** — per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
| 9·5 | ☐ | **Webcam identity key reconciliation** — one stable identity per camera across re-plugs | [`task-09`](TASKS/task-09-live-stream-management.md) |
| 14 | 🔶 | **Creator feedback batch** — Branch 2 shipped; Branch 3 = mic input gain, webcam blank/standby, opacity+geometry consistency, Show-in-folder on stop | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
diff --git a/TASKS/task-55-disk-hygiene.md b/TASKS/task-55-disk-hygiene.md
index f19614a..d59cb7e 100644
--- a/TASKS/task-55-disk-hygiene.md
+++ b/TASKS/task-55-disk-hygiene.md
@@ -30,9 +30,9 @@ ticket and a trust breach.
Guard: `ytLive.Tests/AppLogRotationTests.cs` (the ONE test: the log rolls at the cap, the
live file starts fresh, and the directory still holds exactly `startup.log` + `startup.1.log`
after a SECOND roll — the growth is bounded).
-2. ☐ **Temp alert clip cleanup** — delete the `%TEMP%` alert clip on app close (and sweep
+2. ✅ **Temp alert clip cleanup** — `Services/AlertClipCleanup` deletes the session's clip on close and sweeps `ytLive-alert-*.mp4` at startup (never anything else); shipped 2026-11-10 — delete the `%TEMP%` alert clip on app close (and sweep
stale ones at startup). Test: create, close, assert gone.
-3. ☐ **Low-disk guard for recordings** — check free space on the record folder's drive before
- Record starts, and warn (toast) when it runs low mid-recording. Thresholds TBD with the creator.
+3. ✅ **Low-disk guard for recordings** — `Services/DiskSpaceGuard`: refuse to start under **1 GB** (toast, recording not started, stream continues), warn under **5 GB** at start, one mid-show warning under **2 GB**; shipped 2026-11-10 — check free space on the record folder's drive before
+ Record starts, and warn (toast) when it runs low mid-recording. Thresholds chosen for 8 Mbps ≈ 3.6 GB/h; revisit if tiers change.
**Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it.
diff --git a/ViewModels/MainViewModel.Recording.cs b/ViewModels/MainViewModel.Recording.cs
index 2ade57c..e0f8385 100644
--- a/ViewModels/MainViewModel.Recording.cs
+++ b/ViewModels/MainViewModel.Recording.cs
@@ -16,12 +16,32 @@ public partial class MainViewModel : ViewModelBase
private DateTime _recordStartTime;
private TimeSpan _recordLength;
+ private bool _lowDiskWarned;
+
private string? StartRecordFile()
{
try
{
var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder);
+
+ // TASK 55·3: never start something the drive cannot finish.
+ if (DiskSpaceGuard.FreeBytes(folder) is { } free)
+ {
+ var decision = DiskSpaceGuard.ForStart(free);
+ if (decision == DiskSpaceGuard.StartDecision.Block)
+ {
+ AppLog.Write($"Recording refused: {free / (1024 * 1024)} MB free",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
+ _notifications.Error("Not enough disk space",
+ "There isn't room to record safely. Free up some space or pick another record folder.");
+ return null;
+ }
+ if (decision == DiskSpaceGuard.StartDecision.Warn)
+ _notifications.Warning("Disk space is getting low",
+ "Your recording starts, but there isn't much room left. A long show may not fit.");
+ }
+ _lowDiskWarned = false;
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
_activeRecordPath = path;
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 9f46bcb..b15ddb5 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -641,6 +641,16 @@ public partial class MainViewModel : ViewModelBase
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
+
+ // TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
+ if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
+ && DiskSpaceGuard.FreeBytes(folder) is { } free
+ && DiskSpaceGuard.ShouldWarnDuring(free))
+ {
+ _lowDiskWarned = true;
+ _notifications.Warning("Disk space is nearly gone",
+ "The recording may stop on its own. Free up space now, or stop and save what you have.");
+ }
}
/// What happened to a finished broadcast at the keep-or-delete prompt.
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 10ba32e..e1e8f64 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -599,6 +599,8 @@ public partial class MainViewModel : ViewModelBase
_musicPlayer.Dispose();
_youtubeChat.Dispose();
_alertLayer.Dispose();
+ // TASK 55·2: the materialised default alert clip is ours, so we clean it up on the way out.
+ Services.AlertClipCleanup.Delete(_defaultAlertClipScratch);
}
public void SaveLayoutNow()
diff --git a/ytLive.Tests/DiskHygieneTests.cs b/ytLive.Tests/DiskHygieneTests.cs
new file mode 100644
index 0000000..c318eaf
--- /dev/null
+++ b/ytLive.Tests/DiskHygieneTests.cs
@@ -0,0 +1,70 @@
+using System;
+using System.IO;
+using Xunit;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 55·2–3 — the two disk debts a free app must not leave: the temp alert clip it materialises and
+/// never deletes, and a recording that starts with no room to finish.
+///
+/// Both are tested as policy, because the alternative — filling a real drive — is not a test.
+///
+public sealed class DiskHygieneTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ytLlive-disk-{Guid.NewGuid():N}");
+
+ public DiskHygieneTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
+ }
+
+ [Fact]
+ public void Sweep_RemovesOnlyTheClipsWeCreated()
+ {
+ var ours1 = Path.Combine(_dir, "ytLive-alert-abc.mp4");
+ var ours2 = Path.Combine(_dir, "ytLive-alert-def.mp4");
+ var theirs = Path.Combine(_dir, "someone-elses-recording.mp4");
+ File.WriteAllText(ours1, "x");
+ File.WriteAllText(ours2, "x");
+ File.WriteAllText(theirs, "x");
+
+ var removed = AlertClipCleanup.Sweep(_dir);
+
+ Assert.Equal(2, removed);
+ Assert.False(File.Exists(ours1));
+ Assert.False(File.Exists(ours2));
+ Assert.True(File.Exists(theirs)); // never touch anything that isn't ours
+ }
+
+ [Fact]
+ public void Delete_IsSafeOnMissingBlankAndNull()
+ {
+ Assert.False(AlertClipCleanup.Delete(null));
+ Assert.False(AlertClipCleanup.Delete(""));
+ Assert.False(AlertClipCleanup.Delete(Path.Combine(_dir, "not-there.mp4")));
+
+ var file = Path.Combine(_dir, "ytLive-alert-gone.mp4");
+ File.WriteAllText(file, "x");
+ Assert.True(AlertClipCleanup.Delete(file));
+ Assert.False(File.Exists(file));
+ }
+
+ [Fact]
+ public void RecordingIsRefusedBelowAGigabyte_WarnedBelowFive_AndQuietAbove()
+ {
+ Assert.Equal(DiskSpaceGuard.StartDecision.Block, DiskSpaceGuard.ForStart(500L * 1024 * 1024));
+ Assert.Equal(DiskSpaceGuard.StartDecision.Warn, DiskSpaceGuard.ForStart(2L * 1024 * 1024 * 1024));
+ Assert.Equal(DiskSpaceGuard.StartDecision.Ok, DiskSpaceGuard.ForStart(20L * 1024 * 1024 * 1024));
+ }
+
+ [Fact]
+ public void MidRecordingWarnsOnceBelowTwoGigabytes()
+ {
+ Assert.False(DiskSpaceGuard.ShouldWarnDuring(3L * 1024 * 1024 * 1024));
+ Assert.True(DiskSpaceGuard.ShouldWarnDuring(1L * 1024 * 1024 * 1024));
+ }
+}