diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index b023ae18..158ab1e1 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -45,6 +45,7 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.SessionSkills).HasColumnName("session_skills"); + builder.Property(s => s.ModelPresets).HasColumnName("model_presets"); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } diff --git a/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs index cd48bebc..29caa79e 100644 --- a/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs @@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); + builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 64777e5f..3b9e90c1 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -89,6 +89,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); + builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false); builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); diff --git a/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs new file mode 100644 index 00000000..836240fd --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs @@ -0,0 +1,802 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260727114206_AddModelPresetsAndManualFlag")] + partial class AddModelPresetsAndManualFlag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs new file mode 100644 index 00000000..7d13e4af --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddModelPresetsAndManualFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_manual", + table: "tasks", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "is_manual", + table: "lists", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "model_presets", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "model_presets", + value: null); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "is_manual", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "is_manual", + table: "lists"); + + migrationBuilder.DropColumn( + name: "model_presets", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index ea34a728..a37fd3c3 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -66,6 +66,10 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(1) .HasColumnName("max_parallel_executions"); + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + b.Property("RepoImportFolders") .HasColumnType("TEXT") .HasColumnName("repo_import_folders"); @@ -202,6 +206,12 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue("chore") .HasColumnName("default_commit_type"); + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT") @@ -406,6 +416,12 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("finished_at"); + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + b.Property("IsMyDay") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index e3535d82..c898f1e9 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -30,4 +30,8 @@ public sealed class AppSettingsEntity // JSON array of session skill names applied by default to new tasks. public string? SessionSkills { get; set; } + + // JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and + // turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults. + public string? ModelPresets { get; set; } } diff --git a/src/ClaudeDo.Data/Models/EffortRegistry.cs b/src/ClaudeDo.Data/Models/EffortRegistry.cs new file mode 100644 index 00000000..096d231f --- /dev/null +++ b/src/ClaudeDo.Data/Models/EffortRegistry.cs @@ -0,0 +1,25 @@ +namespace ClaudeDo.Data.Models; + +/// +/// The reasoning-effort levels the claude CLI accepts for --effort. +/// +public static class EffortRegistry +{ + public static readonly IReadOnlyList Levels = new[] { "low", "medium", "high", "xhigh", "max" }; + + public const string DefaultLevel = "high"; + + /// + /// Validate an effort level from external input. Null/blank → null (don't pass --effort). + /// Returns the canonical lowercase level; throws on an unknown value. + /// + public static string? NormalizeLevel(string? effort) + { + var e = effort?.Trim(); + if (string.IsNullOrEmpty(e)) return null; + foreach (var level in Levels) + if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase)) + return level; + throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}."); + } +} diff --git a/src/ClaudeDo.Data/Models/ListEntity.cs b/src/ClaudeDo.Data/Models/ListEntity.cs index b8ec9f1d..517e0e33 100644 --- a/src/ClaudeDo.Data/Models/ListEntity.cs +++ b/src/ClaudeDo.Data/Models/ListEntity.cs @@ -8,6 +8,9 @@ public sealed class ListEntity public string? WorkingDir { get; set; } public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType; public int SortOrder { get; set; } + // A manual list holds reminders rather than Claude work: new tasks created here start out + // manual (see TaskEntity.IsManual). + public bool IsManual { get; set; } // Navigation properties public ListConfigEntity? Config { get; set; } diff --git a/src/ClaudeDo.Data/Models/ModelPresets.cs b/src/ClaudeDo.Data/Models/ModelPresets.cs new file mode 100644 index 00000000..4d06b482 --- /dev/null +++ b/src/ClaudeDo.Data/Models/ModelPresets.cs @@ -0,0 +1,91 @@ +using System.Text.Json; + +namespace ClaudeDo.Data.Models; + +/// Per-model run defaults: which reasoning effort and turn budget a session gets when +/// it runs under and nothing more specific overrides it. +public sealed record ModelPreset(string Model, string Effort, int MaxTurns); + +/// +/// Serialization + lookup for (a JSON array on the +/// singleton settings row). One row per entry; the row supplies +/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat. +/// +public static class ModelPresets +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + /// Shipping defaults — one row per known model alias. + public static IReadOnlyList Defaults { get; } = new[] + { + new ModelPreset("haiku", "medium", 20), + new ModelPreset("sonnet", "high", 30), + new ModelPreset("opus", "high", 40), + new ModelPreset("fable", "high", 25), + }; + + public static string SerializeDefaults() => Serialize(Defaults); + + public static string Serialize(IEnumerable presets) + => JsonSerializer.Serialize(Normalize(presets), Json); + + /// Parses the stored JSON, falling back to for anything absent + /// or malformed — a bad settings row must never stop a run from starting. + public static IReadOnlyList Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Defaults; + try + { + var parsed = JsonSerializer.Deserialize>(json, Json); + return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults; + } + catch (JsonException) + { + return Defaults; + } + } + + /// The preset for , or the default row for it. Never null so + /// callers don't have to special-case an unconfigured or unknown model. + public static ModelPreset For(IReadOnlyList presets, string? model) + { + var alias = (model ?? ModelRegistry.DefaultAlias).Trim(); + foreach (var p in presets) + if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase)) + return p; + foreach (var p in Defaults) + if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase)) + return p; + return new ModelPreset(alias, EffortRegistry.DefaultLevel, 30); + } + + public static ModelPreset For(string? json, string? model) => For(Parse(json), model); + + // One row per known alias, canonical order, clamped values — so an edited or partial list + // still round-trips into something every consumer can rely on. + private static IReadOnlyList Normalize(IEnumerable presets) + { + var byModel = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in presets) + { + if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue; + byModel[p.Model.Trim()] = p; + } + + var result = new List(); + foreach (var fallback in Defaults) + { + var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback; + var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort; + var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns; + result.Add(new ModelPreset(fallback.Model, effort, turns)); + } + return result; + } + + private static string? TryNormalizeEffort(string? effort) + { + try { return EffortRegistry.NormalizeLevel(effort); } + catch (ArgumentException) { return null; } + } +} diff --git a/src/ClaudeDo.Data/Models/ModelRegistry.cs b/src/ClaudeDo.Data/Models/ModelRegistry.cs index 219db35b..57a45c7c 100644 --- a/src/ClaudeDo.Data/Models/ModelRegistry.cs +++ b/src/ClaudeDo.Data/Models/ModelRegistry.cs @@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models; public static class ModelRegistry { - public static readonly IReadOnlyList Aliases = new[] { "sonnet", "opus", "haiku" }; + public static readonly IReadOnlyList Aliases = new[] { "sonnet", "opus", "haiku", "fable" }; /// Model aliases ordered cheapest → most capable. Single source for prompt cost guidance. public static readonly IReadOnlyList ByCostAscending = new[] { "haiku", "sonnet", "opus" }; diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index d288b884..8d0944c1 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -43,6 +43,10 @@ public sealed class TaskEntity public int? MaxTurns { get; set; } public bool IsStarred { get; set; } public bool IsMyDay { get; set; } + // Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep, + // list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still + // allowed. New tasks in a manual list default to true. + public bool IsManual { get; set; } public string? Notes { get; set; } public int SortOrder { get; set; } public string? SessionSkills { get; set; } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index e4e661f5..4261b3ac 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -15,6 +15,9 @@ "defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden", "model": "Modell", "maxTurns": "Max. Durchläufe", + "effort": "Effort", + "modelPresets": "Vorgaben pro Modell", + "modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.", "permission": "Berechtigung", "maxParallelExecutions": "Max. parallele Ausführungen", "maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 70f26da1..88fe7f88 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -15,6 +15,9 @@ "defaultInstructionsPlaceholder": "Baseline instructions applied to every task", "model": "Model", "maxTurns": "Max turns", + "effort": "Effort", + "modelPresets": "Per-model defaults", + "modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.", "permission": "Permission", "maxParallelExecutions": "Max parallel executions", "maxParallelExecutionsHint": "How many queued tasks the worker runs at once.", diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 7d565eda..bc6e18e4 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -607,7 +607,11 @@ public sealed record AppSettingsDto( string? ReportExcludedPaths, int StandupWeekday, int DailyPrepMaxTasks, - List? SessionSkills = null); + List? SessionSkills = null, + List? ModelPresets = null); + +// Per-model run defaults (effort + turn budget) edited in Settings → General. +public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); public sealed record SessionSkillDto( string Name, diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index b74edfe0..c51bcd1b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -47,7 +47,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa [ObservableProperty] private string _effectiveSystemPromptHint = ""; private string _globalModel = ModelRegistry.DefaultAlias; - private int _globalMaxTurns = 100; + // The global max-turns default is per-model (Settings -> General), so it moves with whichever + // model actually ends up in effect here. + private IReadOnlyList _presets = ModelPresets.Defaults; + private string EffectiveModel => Model ?? _listModel ?? _globalModel; + private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns; private string? _listModel; // Task scope only private int? _listMaxTurns; // Task scope only private string? _listAgentName; // Task scope only @@ -56,7 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa private CancellationTokenSource? _saveCts; public int EffectiveMaxTurns => - MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); + MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns); public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); public ObservableCollection Agents { get; } = new(); @@ -97,7 +101,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged; } - partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); } + partial void OnModelChanged(string? value) + { + RecomputeModelBadge(); + // A different model means a different global turn default. + RecomputeTurnsBadge(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + QueueSave(); + } partial void OnMaxTurnsChanged(decimal? value) { @@ -130,8 +141,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa { var own = MaxTurns?.ToString(); var (value, source) = _scope == AgentConfigScope.Task - ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString()) - : InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString()); + ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString()) + : InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString()); TurnsInheritedHint = value; TurnsBadge = BadgeFor(source, MaxTurns is not null); } @@ -284,7 +295,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa { var app = await _worker.GetAppSettingsAsync(); _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; - _globalMaxTurns = app?.DefaultMaxTurns ?? 100; + _presets = app?.ModelPresets is { Count: > 0 } rows + ? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList() + : ModelPresets.Defaults; } private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index d0c0ae1e..2965cada 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -27,6 +27,23 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase public ObservableCollection SessionSkills { get; } = new(); + /// One editable row per model alias: the effort and turn budget a run gets under that + /// model. Supplies the global defaults; list- and task-level max-turns overrides still win. + public ObservableCollection ModelPresets { get; } = new(); + + public void LoadModelPresets(IReadOnlyCollection? presets) + { + ModelPresets.Clear(); + var source = presets is { Count: > 0 } + ? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList() + : Data.Models.ModelPresets.Defaults.ToList(); + foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source))) + ModelPresets.Add(new ModelPresetRowViewModel(p)); + } + + public List ModelPresetDtos() + => ModelPresets.Select(r => new ModelPresetDto(r.Model, r.Effort, (int)r.MaxTurns)).ToList(); + public GeneralSettingsTabViewModel() { } public GeneralSettingsTabViewModel(ILocalizer localizer, Action persist) @@ -50,10 +67,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase public string? Validate() { - if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) - return "Max turns must be between 1 and 200."; if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20) return "Max parallel executions must be between 1 and 20."; + foreach (var row in ModelPresets) + if (row.MaxTurns is < 1 or > 200) + return $"Max turns for {row.Model} must be between 1 and 200."; return null; } @@ -73,3 +91,21 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase return names.Count == 0 ? null : names; } } + +/// One row of the per-model preset table. Single consumer, so it lives here. +public sealed partial class ModelPresetRowViewModel : ViewModelBase +{ + public string Model { get; } + public IReadOnlyList EffortLevels { get; } = EffortRegistry.Levels; + + [ObservableProperty] private string _effort; + // decimal so it binds straight to a NumericUpDown, like the other numeric settings. + [ObservableProperty] private decimal _maxTurns; + + public ModelPresetRowViewModel(ModelPreset preset) + { + Model = preset.Model; + _effort = preset.Effort; + _maxTurns = preset.MaxTurns; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 0842eed6..b28c1f77 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -73,6 +73,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase await OnlineInbox.LoadAsync(); await SessionSkills.LoadAsync(); await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills); + General.LoadModelPresets(dto?.ModelPresets); } finally { IsBusy = false; } } @@ -102,7 +103,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase .Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()), General.StandupWeekday, Prime.DailyPrepMaxTasks, - General.SelectedSessionSkillNames()); + General.SelectedSessionSkillNames(), + General.ModelPresetDtos()); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index cd99eef9..77fe0b62 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -67,7 +67,7 @@ PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}" Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/> - + - - - - + + + + + + + + + + + + + + + + + + + + + + ? SessionSkills = null); + List? SessionSkills = null, + List? ModelPresets = null); + +// Per-model run defaults (effort + turn budget) edited in Settings -> General. +public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record SessionSkillDto( string Name, @@ -318,7 +322,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.ReportExcludedPaths, row.StandupWeekday, row.DailyPrepMaxTasks, - SkillsFromJson(row.SessionSkills)); + SkillsFromJson(row.SessionSkills), + Data.Models.ModelPresets.Parse(row.ModelPresets) + .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList()); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -341,6 +347,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, DailyPrepMaxTasks = dto.DailyPrepMaxTasks, SessionSkills = SkillsToJson(dto.SessionSkills), + // Normalized on the way in (unknown models dropped, effort validated, turns clamped). + ModelPresets = dto.ModelPresets is { Count: > 0 } + ? Data.Models.ModelPresets.Serialize( + dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns))) + : Data.Models.ModelPresets.SerializeDefaults(), }); } diff --git a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs index 137fde3a..56a783f6 100644 --- a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs @@ -11,7 +11,8 @@ public sealed record ClaudeRunConfig( string? PermissionMode = null, string? McpConfigPath = null, string? AllowedTools = null, - IReadOnlyList? SkillNames = null + IReadOnlyList? SkillNames = null, + string? Effort = null ) { public IReadOnlyList SkillNames { get; init; } = SkillNames ?? Array.Empty(); @@ -53,6 +54,12 @@ public sealed class ClaudeArgsBuilder args.Add(config.Model); } + if (!string.IsNullOrWhiteSpace(config.Effort)) + { + args.Add("--effort"); + args.Add(config.Effort); + } + if (config.MaxTurns is int turns && turns > 0) { args.Add("--max-turns"); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index fb654f15..eff7e61e 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -96,6 +96,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) : BuildFreshPromptArgs(task); + // Start the hand-driven session at the effort configured for the model this task would run + // under, instead of inheriting whatever the user's global Claude Code config happens to be. + // The model itself is deliberately NOT forced here — the user can still switch it in the TUI. + args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model)); + // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for // every other tool. @@ -123,7 +128,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService }; return new LaunchSpec( - ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env); + ctx.WorkingDir, resolvedClaude, + WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), + EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), + env); } public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx) @@ -138,10 +146,13 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService }; return new LaunchSpec( - ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env); + ctx.WorkingDir, resolvedClaude, + WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), + EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), + env); } - public Task BuildForDirectoryAsync(string directory, CancellationToken ct) + public async Task BuildForDirectoryAsync(string directory, CancellationToken ct) { if (!Directory.Exists(directory)) throw new InvalidOperationException($"Directory does not exist: {directory}"); @@ -149,12 +160,18 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; - return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env)); + // No task and no list here — the global default model's preset decides the effort. + return new LaunchSpec( + directory, resolvedClaude, + WithEffort(Array.Empty(), EffortFor(settings, settings.DefaultModel)), env); } // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, @@ -209,8 +226,12 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line // positional kickoff LAST — a multi-line positional prompt truncates at the first // newline, so the full multi-line brief travels via the file exposed through --add-dir. + var listConfig = await listRepo.GetConfigAsync(listId, ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + var args = new List { + "--effort", EffortFor(settings, listConfig?.Model), "--permission-mode", "default", "--allowedTools", MergeHelperAllowedTools, "--add-dir", sessionDir, repoDir, @@ -227,6 +248,26 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } + // The reasoning effort configured for a model in Settings → General. Falls back to the shipped + // preset for that model, so a missing/malformed settings row can never block a launch. + private static string EffortFor(AppSettingsEntity settings, string? model) + => ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort; + + // Prepends `--effort `. It has to lead: a positional kickoff prompt must stay last, and + // it may only follow a single-value flag — a variadic flag would swallow it. + private static IReadOnlyList WithEffort(IReadOnlyList args, string effort) + { + var result = new List(args.Count + 2) { "--effort", effort }; + result.AddRange(args); + return result; + } + + private AppSettingsEntity ReadSettings() + { + using var ctx = _dbFactory.CreateDbContext(); + return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult(); + } + // The positional prompt claude opens the interactive session on. Empty (no positional arg) // if the task has neither a title nor a description. private static IReadOnlyList BuildFreshPromptArgs(TaskEntity task) diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 3d362bac..611e3ebc 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -516,14 +516,20 @@ public sealed class TaskRunner var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills); var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct); + // The model decides the global effort/turn defaults: one preset row per model alias + // (Settings → General). List- and task-level max-turns overrides still win. + var model = task.Model ?? listConfig?.Model ?? global.DefaultModel; + var preset = Data.Models.ModelPresets.For(global.ModelPresets, model); + return new ClaudeRunConfig( - Model: task.Model ?? listConfig?.Model ?? global.DefaultModel, + Model: model, SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: resumeSessionId, - MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns), + MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns), PermissionMode: global.DefaultPermissionMode, - SkillNames: skillNames); + SkillNames: skillNames, + Effort: preset.Effort); } private async Task> FilterToInstalledSkillsAsync( diff --git a/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs b/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs new file mode 100644 index 00000000..3aed384c --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs @@ -0,0 +1,91 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Tests; + +public class ModelPresetsTests +{ + [Fact] + public void Defaults_cover_every_known_alias() + { + Assert.Equal( + ModelRegistry.Aliases.OrderBy(a => a), + ModelPresets.Defaults.Select(p => p.Model).OrderBy(m => m)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + public void Parse_falls_back_to_defaults_when_unusable(string? json) + { + Assert.Equal(ModelPresets.Defaults, ModelPresets.Parse(json)); + } + + [Fact] + public void Serialize_roundtrips() + { + var edited = new[] { new ModelPreset("opus", "max", 55) }; + var parsed = ModelPresets.Parse(ModelPresets.Serialize(edited)); + + Assert.Equal("max", ModelPresets.For(parsed, "opus").Effort); + Assert.Equal(55, ModelPresets.For(parsed, "opus").MaxTurns); + } + + [Fact] + public void Serialize_fills_missing_aliases_from_defaults() + { + var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("opus", "max", 55) })); + + Assert.Equal(ModelRegistry.Aliases.Count, parsed.Count); + Assert.Equal(ModelPresets.For(ModelPresets.Defaults, "haiku"), ModelPresets.For(parsed, "haiku")); + } + + [Theory] + [InlineData("nonsense", 30)] // unknown effort → the default row's effort + [InlineData("high", 0)] // turns out of range → the default row's turns + [InlineData("high", 999)] + public void Serialize_rejects_out_of_range_values(string effort, int turns) + { + var fallback = ModelPresets.For(ModelPresets.Defaults, "sonnet"); + var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("sonnet", effort, turns) })); + var row = ModelPresets.For(parsed, "sonnet"); + + if (effort == "nonsense") Assert.Equal(fallback.Effort, row.Effort); + if (turns is 0 or 999) Assert.Equal(fallback.MaxTurns, row.MaxTurns); + } + + [Fact] + public void For_unknown_model_yields_a_usable_row() + { + var row = ModelPresets.For(ModelPresets.Defaults, "some-future-model"); + + Assert.Equal("some-future-model", row.Model); + Assert.Contains(row.Effort, EffortRegistry.Levels); + Assert.True(row.MaxTurns > 0); + } + + [Fact] + public void For_null_model_uses_the_default_alias() + { + Assert.Equal( + ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias), + ModelPresets.For(ModelPresets.Defaults, null)); + } + + [Theory] + [InlineData("low", "low")] + [InlineData("XHIGH", "xhigh")] + [InlineData(" max ", "max")] + public void NormalizeLevel_canonicalizes(string input, string expected) + { + Assert.Equal(expected, EffortRegistry.NormalizeLevel(input)); + } + + [Fact] + public void NormalizeLevel_blank_means_unset_and_unknown_throws() + { + Assert.Null(EffortRegistry.NormalizeLevel(null)); + Assert.Throws(() => EffortRegistry.NormalizeLevel("gigantic")); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs index 8ce806a0..02052daa 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs @@ -26,11 +26,15 @@ public class AgentConfigEditorViewModelTests public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; } } + // The global turn default is per-model now, so the preset row for `model` is what carries + // `turns` — DefaultMaxTurns is no longer consulted. private static AppSettingsDto AppWith(string model, int turns) => new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns, DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling", CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30, - ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5); + ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5, + SessionSkills: null, + ModelPresets: new List { new(model, "high", turns) }); private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) => new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow, diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs index 30e0ad6c..2dd827c0 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs @@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests Assert.DoesNotContain("--append-system-prompt", args); Assert.DoesNotContain("--agents", args); Assert.DoesNotContain("--resume", args); + Assert.DoesNotContain("--effort", args); + } + + [Fact] + public void Effort_Adds_Effort_Flag() + { + var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: "xhigh")); + Assert.Contains("--effort", args); + Assert.Equal("xhigh", args[args.ToList().IndexOf("--effort") + 1]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Blank_Effort_Omits_The_Flag(string? effort) + { + var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: effort)); + Assert.DoesNotContain("--effort", args); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index aed5d6f6..12b7b298 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.True(Directory.Exists(wtRow.Path)); Assert.Equal(wtRow.Path, spec.Cwd); - Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt Assert.Equal(_claudeStubPath, spec.Exe); } @@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); - Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); + Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec)); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); } @@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); + Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec)); } [Fact] @@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt Assert.Equal(_worktreeDir, spec.Cwd); } @@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt } [Fact] @@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args); + Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec)); } [Fact] @@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); - Assert.Empty(spec.Args); + Assert.Empty(ArgsAfterEffort(spec)); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Empty(_seeder.Calls); } @@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable // ── Merge helper ── + /// Every ConPTY spec now leads with `--effort ` from the per-model preset (the seeded + /// settings row has no overrides, so the shipped default for the default model applies). + /// Asserts that pair and returns the rest of the args for the test's own comparison. + private static string[] ArgsAfterEffort(LaunchSpec spec) + { + var args = spec.Args.ToList(); + Assert.Equal("--effort", args[0]); + Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]); + return args.Skip(2).ToArray(); + } + private readonly List _mergeHelperSessionDirs = new(); /// The session dir is the value right after --add-dir; register it for cleanup @@ -498,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var spec = BuildService().BuildPlanningResume(ctx); - Assert.Equal("--permission-mode", spec.Args[0]); - Assert.Equal("default", spec.Args[1]); - Assert.Equal("--allowedTools", spec.Args[2]); - Assert.Contains("mcp__claudedo__", spec.Args[3]); - Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray()); + var args = ArgsAfterEffort(spec); + Assert.Equal("--permission-mode", args[0]); + Assert.Equal("default", args[1]); + Assert.Equal("--allowedTools", args[2]); + Assert.Contains("mcp__claudedo__", args[3]); + Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray()); Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal(_worktreeDir, spec.Cwd); }