feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings

Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
This commit is contained in:
mika kuns
2026-09-02 19:09:47 +02:00
parent 20b3b4ce2b
commit 69898be5c9
21 changed files with 1596 additions and 14 deletions
+8
View File
@@ -41,10 +41,18 @@ Beyond the basics it carries:
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the
singleton row — a property missing from that block never persists, however correctly the caller
filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and
`ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column
to that block, not just to the entity + the hub.
### Model / effort registries
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
@@ -72,6 +72,12 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.PowerUseEnabled)
.HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false);
builder.Property(s => s.PowerUseWindowStart)
.HasColumnName("power_use_window_start");
builder.Property(s => s.PowerUseWindowEnd)
.HasColumnName("power_use_window_end");
builder.Property(s => s.PowerUseHandoffPct)
.HasColumnName("power_use_handoff_pct").IsRequired().HasDefaultValue(95);
builder.Property(s => s.ListHandlerMaxHandoffs)
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerUseWindow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "power_use_handoff_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 95);
migrationBuilder.AddColumn<string>(
name: "power_use_window_end",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "power_use_window_start",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "power_use_handoff_pct", "power_use_window_end", "power_use_window_start" },
values: new object[] { 95, null, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "power_use_handoff_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_end",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_start",
table: "app_settings");
}
}
}
@@ -100,6 +100,20 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false)
.HasColumnName("power_use_enabled");
b.Property<int>("PowerUseHandoffPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(95)
.HasColumnName("power_use_handoff_pct");
b.Property<string>("PowerUseWindowEnd")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_end");
b.Property<string>("PowerUseWindowStart")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_start");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
@@ -192,6 +206,7 @@ namespace ClaudeDo.Data.Migrations
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
PowerUseEnabled = false,
PowerUseHandoffPct = 95,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
@@ -69,6 +69,17 @@ public sealed class AppSettingsEntity
// run unattended. Composition lives in InteractiveLaunchSpecService.
public bool PowerUseEnabled { get; set; }
// Optional local-time window (both `HH:mm`) narrowing when PowerUse applies, checked per phase
// at prompt-build time. Both null (the default) = round the clock, byte-identical to the
// pre-window behaviour; only one set counts as unset too. End before start wraps over midnight.
// Semantics live in PowerUseWindow.
public string? PowerUseWindowStart { get; set; }
public string? PowerUseWindowEnd { get; set; }
// Percentage of the 5h usage window at which a PowerUse handler session is told to hand off
// rather than start more work. Stored and displayed here; the consumer is the handoff prompt.
public int PowerUseHandoffPct { get; set; } = 95;
// Server-side cap on a list-handler run's wait<->merge handoff chain (HandoffRoundTracker).
// 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep
// going for hours. It exists to bound runaway handoff loops, so it must stay >= 1.
@@ -0,0 +1,32 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// The optional local-time window restricting when PowerUse applies
/// (<see cref="AppSettingsEntity.PowerUseWindowStart"/> / <c>End</c>, both <c>HH:mm</c>).
/// Pure on purpose — the caller supplies "now", so the window logic is testable without a clock.
/// </summary>
public static class PowerUseWindow
{
/// <summary>Parses an <c>HH:mm</c> string; anything else (including null/blank) is null.</summary>
public static TimeOnly? Parse(string? value)
=> TimeOnly.TryParseExact(value?.Trim(), "HH:mm", out var t) ? t : null;
/// <summary>Round-trips a stored/typed value through <see cref="Parse"/>, so an unparseable
/// string is stored as null instead of sitting in the column pretending to be a window.</summary>
public static string? Normalize(string? value) => Parse(value)?.ToString("HH:mm");
/// <summary>
/// True when <paramref name="now"/> falls inside the window. Half-open: start is included,
/// end is not. Either bound missing = no window at all = always true (the pre-window
/// behaviour). A window whose end is before its start wraps over midnight.
/// </summary>
public static bool Contains(TimeOnly now, TimeOnly? start, TimeOnly? end)
{
if (start is not { } from || end is not { } to) return true;
return from < to ? now >= from && now < to : now >= from || now < to;
}
/// <summary>Convenience overload for the two stored strings and a wall clock.</summary>
public static bool IsOpen(DateTime now, string? start, string? end)
=> Contains(TimeOnly.FromDateTime(now), Parse(start), Parse(end));
}
@@ -93,6 +93,16 @@ public sealed class AppSettingsRepository
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit;
// ModelPresets / PowerUse* / ListHandlerMaxHandoffs were missing from this copy block, so
// every write of them was silently dropped (the row keeps whatever it already had). This
// block is the single write path for the singleton row — a field absent here does not
// persist, no matter how correctly the hub fills the entity.
row.ModelPresets = string.IsNullOrWhiteSpace(updated.ModelPresets) ? row.ModelPresets : updated.ModelPresets;
row.PowerUseEnabled = updated.PowerUseEnabled;
row.PowerUseWindowStart = PowerUseWindow.Normalize(updated.PowerUseWindowStart);
row.PowerUseWindowEnd = PowerUseWindow.Normalize(updated.PowerUseWindowEnd);
row.PowerUseHandoffPct = Math.Clamp(updated.PowerUseHandoffPct, 50, 99);
row.ListHandlerMaxHandoffs = Math.Max(1, updated.ListHandlerMaxHandoffs);
await _context.SaveChangesAsync(ct);
}
+7 -1
View File
@@ -40,7 +40,13 @@ public record AppSettingsDto(
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
// effect for a list that also set PowerUseAllowed.
bool PowerUseEnabled = false,
int ListHandlerMaxHandoffs = 4);
int ListHandlerMaxHandoffs = 4,
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
// which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has
// to round-trip them too.
string? PowerUseWindowStart = null,
string? PowerUseWindowEnd = null,
int PowerUseHandoffPct = 95);
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -511,6 +511,10 @@
"noGauges": "Noch keine Nutzungslimits gemeldet.",
"powerUse": "PowerUse",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"powerUseWindow": "Nur zwischen",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
"refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})",
@@ -511,6 +511,10 @@
"noGauges": "No usage limits reported yet.",
"powerUse": "PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"powerUseWindow": "Only between",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Hand off at (% of 5h)",
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
"refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})",
@@ -134,6 +134,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
// here, but they must survive a Settings save.
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
public async Task LoadAsync()
{
IsBusy = true;
@@ -156,6 +160,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -224,7 +229,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -108,6 +109,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
[ObservableProperty] private int _powerUseHandoffPct = 95;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
@@ -115,13 +124,16 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUseAsync(value);
_ = SavePowerUse();
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
/// value the user just typed into a sibling box.
[RelayCommand]
private async Task SavePowerUse()
{
if (_loadingPowerUse) return;
try
{
var settings = await _worker.GetAppSettingsAsync();
@@ -131,7 +143,28 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
// The worker stores an unparseable time as null; normalize here too so the box shows
// what was actually saved instead of a typo that looks like a live window.
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
await _worker.UpdateAppSettingsAsync(settings with
{
PowerUseEnabled = PowerUseEnabled,
PowerUseWindowStart = start,
PowerUseWindowEnd = end,
PowerUseHandoffPct = pct,
});
_loadingPowerUse = true;
try
{
PowerUseWindowStart = start;
PowerUseWindowEnd = end;
PowerUseHandoffPct = pct;
}
finally { _loadingPowerUse = false; }
}
catch (Exception ex)
{
@@ -192,7 +225,13 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
try
{
PowerUseEnabled = settings.PowerUseEnabled;
PowerUseWindowStart = settings.PowerUseWindowStart;
PowerUseWindowEnd = settings.PowerUseWindowEnd;
PowerUseHandoffPct = settings.PowerUseHandoffPct;
}
finally { _loadingPowerUse = false; }
}
@@ -144,6 +144,29 @@
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
<!-- Window + handoff threshold. Same commit-on-blur/Enter discipline as the gauge
legend boxes: a hub write per keystroke would be one save per typed digit. -->
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.powerUseWindow}"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowStart, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowEnd, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Margin="12,0,0,0"
Text="{loc:Tr modals.usageMonitor.powerUseHandoffPct}"/>
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
Minimum="50" Maximum="99" Increment="1" FormatString="0"
Value="{Binding PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
</StackPanel>
<TextBlock Classes="meta" TextWrapping="Wrap" Opacity="0.75"
Text="{loc:Tr modals.usageMonitor.powerUseWindowHint}"/>
</StackPanel>
</Border>
@@ -33,4 +33,19 @@ public partial class UsageMonitorModalView : Window
OnStageBoxCommit(sender, e);
e.Handled = true;
}
/// <summary>Persists the PowerUse window / handoff threshold on blur — a save per keystroke
/// would be one hub round-trip per typed digit.</summary>
private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e)
{
if (DataContext is not UsageMonitorModalViewModel vm) return;
if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null);
}
private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnPowerUseBoxCommit(sender, e);
e.Handled = true;
}
}
+10 -1
View File
@@ -323,7 +323,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.UsageThrottleSevenDayHardPct,
row.AutoContinueOnUsageLimit,
row.PowerUseEnabled,
row.ListHandlerMaxHandoffs);
row.ListHandlerMaxHandoffs,
row.PowerUseWindowStart,
row.PowerUseWindowEnd,
row.PowerUseHandoffPct);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -363,6 +366,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
// client would remove that bound entirely, so clamp instead of trusting the wire value.
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
// An unparseable time string is stored as null (= no window) rather than rejected: the
// window is an optional narrowing, and a typo must not silently mean "PowerUse never
// applies". Below 50% a handoff threshold would fire before the session got anywhere.
PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart),
PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd),
PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99),
});
}
@@ -45,6 +45,9 @@ public sealed class InteractiveLaunchSpecService
private readonly string _claudePath;
private readonly HandoffRoundTracker _handoffRounds;
// Local wall clock, injectable so the PowerUse time window is testable without sleeping.
private readonly Func<DateTime> _now;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ISessionSkillSeeder skillSeeder,
@@ -52,8 +55,10 @@ public sealed class InteractiveLaunchSpecService
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg,
HandoffRoundTracker handoffRounds)
HandoffRoundTracker handoffRounds,
Func<DateTime>? now = null)
{
_now = now ?? (() => DateTime.Now);
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
_skillRegistry = skillRegistry;
@@ -580,7 +585,11 @@ public sealed class InteractiveLaunchSpecService
// Appending rather than substituting a second prompt set keeps the shared 95% in one place --
// the addendum is written to read as an override of the base prompt's interactive rules. A run
// that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed.
private static async Task<string> ComposeHandlerPromptAsync(
//
// The optional time window is checked HERE, i.e. per phase at prompt-build time: a chain that
// hands off out of the window drops the addendum from that point on instead of staying
// unattended because the first phase happened to start inside it.
private async Task<string> ComposeHandlerPromptAsync(
ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct)
{
var basePrompt = PromptFiles.ReadOrDefault(kind);
@@ -588,6 +597,9 @@ public sealed class InteractiveLaunchSpecService
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
if (!settings.PowerUseEnabled) return basePrompt;
if (!PowerUseWindow.IsOpen(_now(), settings.PowerUseWindowStart, settings.PowerUseWindowEnd))
return basePrompt;
var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct);
if (listConfig?.PowerUseAllowed != true) return basePrompt;