From 200583dacab46a8b357d1ce777fa3e561c364e66 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:31:43 -0700 Subject: [PATCH 01/36] feat(core): add all wow features infrastructure types - Add Cronos package for cron expression parsing - Add enums: MatchStrategy, DecisionStepType, MilestoneType - Add models: DejaVuAlert, SessionSummary, DecisionChain, BlastRadius, GrowthModels - Add interfaces: IDeadEndStore, IAlertSink - Extend AgentContext with IDeadEndStore - Extend IGraphStore with multi-edge-type GetNeighbors overload - Extend IObservationStore with GetSessionObservations - Extend AgentOutputType with 5 new members - Add centralized Prompts.cs with all LLM prompt templates Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/DevBrain.Agents.csproj | 1 + src/DevBrain.Core/Enums/DecisionStepType.cs | 9 ++ src/DevBrain.Core/Enums/MatchStrategy.cs | 7 + src/DevBrain.Core/Enums/MilestoneType.cs | 8 + src/DevBrain.Core/Interfaces/IAlertSink.cs | 8 + src/DevBrain.Core/Interfaces/IDeadEndStore.cs | 21 +++ src/DevBrain.Core/Interfaces/IGraphStore.cs | 1 + .../Interfaces/IIntelligenceAgent.cs | 1 + .../Interfaces/IObservationStore.cs | 1 + src/DevBrain.Core/Models/AgentOutput.cs | 7 +- src/DevBrain.Core/Models/BlastRadius.cs | 19 +++ src/DevBrain.Core/Models/DecisionChain.cs | 22 +++ src/DevBrain.Core/Models/DejaVuAlert.cs | 15 ++ src/DevBrain.Core/Models/GrowthModels.cs | 34 ++++ src/DevBrain.Core/Models/SessionSummary.cs | 15 ++ src/DevBrain.Core/Prompts.cs | 148 ++++++++++++++++++ 16 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/DevBrain.Core/Enums/DecisionStepType.cs create mode 100644 src/DevBrain.Core/Enums/MatchStrategy.cs create mode 100644 src/DevBrain.Core/Enums/MilestoneType.cs create mode 100644 src/DevBrain.Core/Interfaces/IAlertSink.cs create mode 100644 src/DevBrain.Core/Interfaces/IDeadEndStore.cs create mode 100644 src/DevBrain.Core/Models/BlastRadius.cs create mode 100644 src/DevBrain.Core/Models/DecisionChain.cs create mode 100644 src/DevBrain.Core/Models/DejaVuAlert.cs create mode 100644 src/DevBrain.Core/Models/GrowthModels.cs create mode 100644 src/DevBrain.Core/Models/SessionSummary.cs create mode 100644 src/DevBrain.Core/Prompts.cs diff --git a/src/DevBrain.Agents/DevBrain.Agents.csproj b/src/DevBrain.Agents/DevBrain.Agents.csproj index 2551306..8d3f35f 100644 --- a/src/DevBrain.Agents/DevBrain.Agents.csproj +++ b/src/DevBrain.Agents/DevBrain.Agents.csproj @@ -7,6 +7,7 @@ + diff --git a/src/DevBrain.Core/Enums/DecisionStepType.cs b/src/DevBrain.Core/Enums/DecisionStepType.cs new file mode 100644 index 0000000..a7befb1 --- /dev/null +++ b/src/DevBrain.Core/Enums/DecisionStepType.cs @@ -0,0 +1,9 @@ +namespace DevBrain.Core.Enums; + +public enum DecisionStepType +{ + Decision, + DeadEnd, + Error, + Resolution +} diff --git a/src/DevBrain.Core/Enums/MatchStrategy.cs b/src/DevBrain.Core/Enums/MatchStrategy.cs new file mode 100644 index 0000000..1a09862 --- /dev/null +++ b/src/DevBrain.Core/Enums/MatchStrategy.cs @@ -0,0 +1,7 @@ +namespace DevBrain.Core.Enums; + +public enum MatchStrategy +{ + FileOverlap, + Semantic +} diff --git a/src/DevBrain.Core/Enums/MilestoneType.cs b/src/DevBrain.Core/Enums/MilestoneType.cs new file mode 100644 index 0000000..f7d40a8 --- /dev/null +++ b/src/DevBrain.Core/Enums/MilestoneType.cs @@ -0,0 +1,8 @@ +namespace DevBrain.Core.Enums; + +public enum MilestoneType +{ + First, + Streak, + Improvement +} diff --git a/src/DevBrain.Core/Interfaces/IAlertSink.cs b/src/DevBrain.Core/Interfaces/IAlertSink.cs new file mode 100644 index 0000000..252ea0c --- /dev/null +++ b/src/DevBrain.Core/Interfaces/IAlertSink.cs @@ -0,0 +1,8 @@ +namespace DevBrain.Core.Interfaces; + +using DevBrain.Core.Models; + +public interface IAlertSink +{ + Task Send(DejaVuAlert alert, CancellationToken ct = default); +} diff --git a/src/DevBrain.Core/Interfaces/IDeadEndStore.cs b/src/DevBrain.Core/Interfaces/IDeadEndStore.cs new file mode 100644 index 0000000..ece4c4a --- /dev/null +++ b/src/DevBrain.Core/Interfaces/IDeadEndStore.cs @@ -0,0 +1,21 @@ +namespace DevBrain.Core.Interfaces; + +using DevBrain.Core.Models; + +public record DeadEndFilter +{ + public string? Project { get; init; } + public string? ThreadId { get; init; } + public DateTime? After { get; init; } + public DateTime? Before { get; init; } + public int Limit { get; init; } = 50; + public int Offset { get; init; } = 0; +} + +public interface IDeadEndStore +{ + Task Add(DeadEnd deadEnd); + Task> Query(DeadEndFilter filter); + Task> FindByFiles(IReadOnlyList filePaths); + Task> FindSimilar(string description, int limit = 5); +} diff --git a/src/DevBrain.Core/Interfaces/IGraphStore.cs b/src/DevBrain.Core/Interfaces/IGraphStore.cs index df813ca..fe9aa33 100644 --- a/src/DevBrain.Core/Interfaces/IGraphStore.cs +++ b/src/DevBrain.Core/Interfaces/IGraphStore.cs @@ -13,6 +13,7 @@ public interface IGraphStore Task RemoveEdge(string id); Task> GetNeighbors(string nodeId, int hops = 1, string? edgeType = null); + Task> GetNeighbors(string nodeId, int hops, IReadOnlyList edgeTypes); Task> FindPaths(string fromId, string toId, int maxDepth = 4); Task> GetRelatedToFile(string filePath); diff --git a/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs b/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs index 2fa8e93..9339ab1 100644 --- a/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs +++ b/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs @@ -8,6 +8,7 @@ public record AgentContext( IGraphStore Graph, IVectorStore Vectors, ILlmService Llm, + IDeadEndStore DeadEnds, Settings Settings ); diff --git a/src/DevBrain.Core/Interfaces/IObservationStore.cs b/src/DevBrain.Core/Interfaces/IObservationStore.cs index 9bb9544..dd7f5ff 100644 --- a/src/DevBrain.Core/Interfaces/IObservationStore.cs +++ b/src/DevBrain.Core/Interfaces/IObservationStore.cs @@ -27,4 +27,5 @@ public interface IObservationStore Task GetDatabaseSizeBytes(); Task DeleteByProject(string project); Task DeleteBefore(DateTime before); + Task> GetSessionObservations(string sessionId, int limit = 500); } diff --git a/src/DevBrain.Core/Models/AgentOutput.cs b/src/DevBrain.Core/Models/AgentOutput.cs index 6c9568a..395eee5 100644 --- a/src/DevBrain.Core/Models/AgentOutput.cs +++ b/src/DevBrain.Core/Models/AgentOutput.cs @@ -6,7 +6,12 @@ public enum AgentOutputType BriefingGenerated, EdgeCreated, ThreadCompressed, - PatternDetected + PatternDetected, + AlertFired, + StoryGenerated, + DecisionChainBuilt, + GrowthReportGenerated, + MilestoneAchieved } public record AgentOutput(AgentOutputType Type, string Content, object? Data = null); diff --git a/src/DevBrain.Core/Models/BlastRadius.cs b/src/DevBrain.Core/Models/BlastRadius.cs new file mode 100644 index 0000000..e6362aa --- /dev/null +++ b/src/DevBrain.Core/Models/BlastRadius.cs @@ -0,0 +1,19 @@ +namespace DevBrain.Core.Models; + +public record BlastRadius +{ + public required string SourceFile { get; init; } + public IReadOnlyList AffectedFiles { get; init; } = []; + public IReadOnlyList DeadEndsAtRisk { get; init; } = []; + public string? Summary { get; init; } + public DateTime GeneratedAt { get; init; } = DateTime.UtcNow; +} + +public record BlastRadiusEntry +{ + public required string FilePath { get; init; } + public required double RiskScore { get; init; } + public required int ChainLength { get; init; } + public required string Reason { get; init; } + public IReadOnlyList DecisionChain { get; init; } = []; +} diff --git a/src/DevBrain.Core/Models/DecisionChain.cs b/src/DevBrain.Core/Models/DecisionChain.cs new file mode 100644 index 0000000..b6bf0cf --- /dev/null +++ b/src/DevBrain.Core/Models/DecisionChain.cs @@ -0,0 +1,22 @@ +namespace DevBrain.Core.Models; + +using DevBrain.Core.Enums; + +public record DecisionChain +{ + public required string Id { get; init; } + public required string RootNodeId { get; init; } + public required string Narrative { get; init; } + public IReadOnlyList Steps { get; init; } = []; + public DateTime GeneratedAt { get; init; } = DateTime.UtcNow; +} + +public record DecisionStep +{ + public required string ObservationId { get; init; } + public required string Summary { get; init; } + public required DateTime Timestamp { get; init; } + public required DecisionStepType StepType { get; init; } + public IReadOnlyList FilesInvolved { get; init; } = []; + public IReadOnlyList AlternativesRejected { get; init; } = []; +} diff --git a/src/DevBrain.Core/Models/DejaVuAlert.cs b/src/DevBrain.Core/Models/DejaVuAlert.cs new file mode 100644 index 0000000..9a3c4d4 --- /dev/null +++ b/src/DevBrain.Core/Models/DejaVuAlert.cs @@ -0,0 +1,15 @@ +namespace DevBrain.Core.Models; + +using DevBrain.Core.Enums; + +public record DejaVuAlert +{ + public required string Id { get; init; } + public required string ThreadId { get; init; } + public required string MatchedDeadEndId { get; init; } + public required double Confidence { get; init; } + public required string Message { get; init; } + public required MatchStrategy Strategy { get; init; } + public bool Dismissed { get; init; } = false; + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/DevBrain.Core/Models/GrowthModels.cs b/src/DevBrain.Core/Models/GrowthModels.cs new file mode 100644 index 0000000..95b9465 --- /dev/null +++ b/src/DevBrain.Core/Models/GrowthModels.cs @@ -0,0 +1,34 @@ +namespace DevBrain.Core.Models; + +using DevBrain.Core.Enums; + +public record DeveloperMetric +{ + public required string Id { get; init; } + public required string Dimension { get; init; } + public required double Value { get; init; } + public required DateTime PeriodStart { get; init; } + public required DateTime PeriodEnd { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} + +public record GrowthMilestone +{ + public required string Id { get; init; } + public required MilestoneType Type { get; init; } + public required string Description { get; init; } + public required DateTime AchievedAt { get; init; } + public string? ObservationId { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} + +public record GrowthReport +{ + public required string Id { get; init; } + public required DateTime PeriodStart { get; init; } + public required DateTime PeriodEnd { get; init; } + public IReadOnlyList Metrics { get; init; } = []; + public IReadOnlyList Milestones { get; init; } = []; + public string? Narrative { get; init; } + public DateTime GeneratedAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/DevBrain.Core/Models/SessionSummary.cs b/src/DevBrain.Core/Models/SessionSummary.cs new file mode 100644 index 0000000..7d32393 --- /dev/null +++ b/src/DevBrain.Core/Models/SessionSummary.cs @@ -0,0 +1,15 @@ +namespace DevBrain.Core.Models; + +public record SessionSummary +{ + public required string Id { get; init; } + public required string SessionId { get; init; } + public required string Narrative { get; init; } + public required string Outcome { get; init; } + public required TimeSpan Duration { get; init; } + public required int ObservationCount { get; init; } + public required int FilesTouched { get; init; } + public required int DeadEndsHit { get; init; } + public IReadOnlyList Phases { get; init; } = []; + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/DevBrain.Core/Prompts.cs b/src/DevBrain.Core/Prompts.cs new file mode 100644 index 0000000..0fad97d --- /dev/null +++ b/src/DevBrain.Core/Prompts.cs @@ -0,0 +1,148 @@ +namespace DevBrain.Core; + +public static class Prompts +{ + // -- Feature 2: Session Storytelling -- + + public const string StorytellerNarrative = """ + Write a developer session narrative from these events. + + Session duration: {0} + Phases: {1} + Turning points: {2} + + Events: + {3} + + Rules: + - Past tense, third person ("The developer...") + - Structure: Goal -> Approach -> Obstacles -> Resolution + - Mention specific files and decisions by name + - Note dead ends and what they taught + - End with one-line "session outcome" + - Under 300 words + """; + + // -- Feature 3: Decision Replay -- + + public const string DecisionClassification = """ + Given two developer decisions about the same codebase, classify their relationship. + + Decision A (earlier): {0} + Decision B (later): {1} + Shared files: {2} + + Classify as ONE of: + - caused_by: B was motivated by A + - supersedes: B replaces A + - resolved_by: B resolves a problem A introduced + - unrelated: no causal connection + + Respond with ONLY the classification label. + """; + + public const string DecisionChainNarrative = """ + Explain why this code exists by narrating the chain of decisions that led to it. + + File: {0} + Decision chain (chronological): + {1} + + Rules: + - Explain the "why" behind each decision + - Note alternatives that were rejected and why + - Highlight dead ends that were hit along the way + - Keep under 200 words + """; + + // -- Feature 4: Blast Radius Prediction -- + + public const string BlastRadiusSummary = """ + Summarize the potential impact of changing this file. + + File being changed: {0} + Affected files and their connection: + {1} + Dead ends at risk of re-triggering: + {2} + + Rules: + - Focus on the highest-risk impacts + - Explain WHY each file is affected (the decision that connects them) + - Warn about dead ends that could resurface + - Keep under 150 words + """; + + // -- Feature 5: Growth Tracker - Complexity -- + + public const string ComplexityClassification = """ + Rate the complexity of this development task from 1-5: + 1 = Routine (config changes, simple CRUD, renames) + 2 = Moderate (new feature with clear requirements) + 3 = Significant (cross-cutting changes, new abstractions) + 4 = Complex (architectural decisions, novel algorithms) + 5 = Expert (system design, performance-critical, multi-system integration) + + Thread summary: {0} + Files changed: {1} + Decisions made: {2} + Errors encountered: {3} + + Respond with ONLY the number. + """; + + // -- Feature 5: Growth Tracker - Quality -- + + public const string ErrorClassification = """ + Classify this development error into ONE category: + - logic_bug: incorrect logic, wrong algorithm, bad assumption + - typo: syntax error, misspelling, wrong variable name + - environment: config issue, missing dependency, wrong version + - external: third-party API failure, network issue, dependency bug + + Error: {0} + Context: {1} + + Respond with ONLY the category. + """; + + // -- Feature 5: Growth Tracker - Weekly Narrative -- + + public const string GrowthNarrative = """ + Given these developer metrics for the past week, write 2-3 sentences + highlighting the most interesting trend or achievement. + + Metrics: {0} + Milestones: {1} + 4-week trend: {2} + Complexity trend: {3} + Quality trend: {4} + Error breakdown: {5} + + Rules: + - Be encouraging but honest + - Focus on growth, not absolute numbers + - Never compare to other developers + - If complexity is rising while quality holds, highlight this prominently + - Keep under 100 words + """; + + // -- Existing: Briefing Agent (migrated from BriefingAgent.cs) -- + + public const string BriefingGeneration = """ + Generate a daily development briefing based on the following observations from the last 24 hours. + Summarize key decisions, errors encountered, files changed, and overall progress. + Format as markdown with sections. + + Observations: + {0} + """; + + // -- Existing: Compression Agent (migrated from CompressionAgent.cs) -- + + public const string CompressionSummarization = """ + Summarize the following development observation concisely: + + {0} + """; +} From a31346334767f8e55bc3d4bba72035042b4a0558 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:32:49 -0700 Subject: [PATCH 02/36] feat(storage): add wow features tables to SchemaManager Tables: deja_vu_alerts, session_summaries, developer_metrics, milestones, growth_reports with appropriate indexes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Storage/Schema/SchemaManager.cs | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/DevBrain.Storage/Schema/SchemaManager.cs b/src/DevBrain.Storage/Schema/SchemaManager.cs index 994e424..b484b7a 100644 --- a/src/DevBrain.Storage/Schema/SchemaManager.cs +++ b/src/DevBrain.Storage/Schema/SchemaManager.cs @@ -118,6 +118,67 @@ INSERT INTO observations_fts(observations_fts, rowid, summary, raw_content, tags INSERT INTO observations_fts(rowid, summary, raw_content, tags) VALUES (new.rowid, new.summary, new.raw_content, new.tags); END; + + CREATE TABLE IF NOT EXISTS deja_vu_alerts ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + matched_dead_end_id TEXT NOT NULL, + confidence REAL NOT NULL, + message TEXT NOT NULL, + strategy TEXT NOT NULL, + dismissed INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_dva_dedup ON deja_vu_alerts(thread_id, matched_dead_end_id); + CREATE INDEX IF NOT EXISTS idx_dva_active ON deja_vu_alerts(dismissed); + + CREATE TABLE IF NOT EXISTS session_summaries ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + narrative TEXT NOT NULL, + outcome TEXT NOT NULL, + duration_seconds INTEGER NOT NULL, + observation_count INTEGER NOT NULL, + files_touched INTEGER NOT NULL, + dead_ends_hit INTEGER NOT NULL, + phases TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_ss_session ON session_summaries(session_id); + + CREATE TABLE IF NOT EXISTS developer_metrics ( + id TEXT PRIMARY KEY, + dimension TEXT NOT NULL, + value REAL NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_dm_dimension ON developer_metrics(dimension, period_start); + + CREATE TABLE IF NOT EXISTS milestones ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + description TEXT NOT NULL, + achieved_at TEXT NOT NULL, + observation_id TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_ms_type ON milestones(type, achieved_at); + + CREATE TABLE IF NOT EXISTS growth_reports ( + id TEXT PRIMARY KEY, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + metrics TEXT NOT NULL, + milestones TEXT NOT NULL, + narrative TEXT, + generated_at TEXT NOT NULL + ); """; cmd.ExecuteNonQuery(); } From 0382b366dd415683b5d26ed991a59a0bc375b7c0 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:35:14 -0700 Subject: [PATCH 03/36] feat(storage): implement SqliteDeadEndStore, GetSessionObservations, multi-edge GetNeighbors - SqliteDeadEndStore with CRUD, FindByFiles, FindSimilar - GetSessionObservations on SqliteObservationStore - Multi-edge-type GetNeighbors overload on SqliteGraphStore - 7 new tests, all passing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Storage/SqliteDeadEndStore.cs | 135 ++++++++++++++++++ src/DevBrain.Storage/SqliteGraphStore.cs | 44 ++++++ .../SqliteObservationStore.cs | 14 ++ .../SqliteDeadEndStoreTests.cs | 112 +++++++++++++++ .../SqliteGraphStoreMultiEdgeTests.cs | 75 ++++++++++ 5 files changed, 380 insertions(+) create mode 100644 src/DevBrain.Storage/SqliteDeadEndStore.cs create mode 100644 tests/DevBrain.Storage.Tests/SqliteDeadEndStoreTests.cs create mode 100644 tests/DevBrain.Storage.Tests/SqliteGraphStoreMultiEdgeTests.cs diff --git a/src/DevBrain.Storage/SqliteDeadEndStore.cs b/src/DevBrain.Storage/SqliteDeadEndStore.cs new file mode 100644 index 0000000..2df16c8 --- /dev/null +++ b/src/DevBrain.Storage/SqliteDeadEndStore.cs @@ -0,0 +1,135 @@ +namespace DevBrain.Storage; + +using System.Globalization; +using System.Text.Json; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using Microsoft.Data.Sqlite; + +public class SqliteDeadEndStore : IDeadEndStore +{ + private readonly SqliteConnection _connection; + + public SqliteDeadEndStore(SqliteConnection connection) + { + _connection = connection; + } + + public async Task Add(DeadEnd deadEnd) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO dead_ends (id, thread_id, project, description, approach, reason, + files_involved, detected_at, created_at) + VALUES (@id, @threadId, @project, @description, @approach, @reason, + @filesInvolved, @detectedAt, @createdAt) + """; + + cmd.Parameters.AddWithValue("@id", deadEnd.Id); + cmd.Parameters.AddWithValue("@threadId", (object?)deadEnd.ThreadId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@project", deadEnd.Project); + cmd.Parameters.AddWithValue("@description", deadEnd.Description); + cmd.Parameters.AddWithValue("@approach", deadEnd.Approach); + cmd.Parameters.AddWithValue("@reason", deadEnd.Reason); + cmd.Parameters.AddWithValue("@filesInvolved", JsonSerializer.Serialize(deadEnd.FilesInvolved)); + cmd.Parameters.AddWithValue("@detectedAt", deadEnd.DetectedAt.ToString("o")); + cmd.Parameters.AddWithValue("@createdAt", deadEnd.CreatedAt.ToString("o")); + + await cmd.ExecuteNonQueryAsync(); + return deadEnd; + } + + public async Task> Query(DeadEndFilter filter) + { + using var cmd = _connection.CreateCommand(); + var clauses = new List(); + + if (filter.Project is not null) + { + clauses.Add("project = @project"); + cmd.Parameters.AddWithValue("@project", filter.Project); + } + if (filter.ThreadId is not null) + { + clauses.Add("thread_id = @threadId"); + cmd.Parameters.AddWithValue("@threadId", filter.ThreadId); + } + if (filter.After is not null) + { + clauses.Add("detected_at > @after"); + cmd.Parameters.AddWithValue("@after", filter.After.Value.ToString("o")); + } + if (filter.Before is not null) + { + clauses.Add("detected_at < @before"); + cmd.Parameters.AddWithValue("@before", filter.Before.Value.ToString("o")); + } + + var where = clauses.Count > 0 ? "WHERE " + string.Join(" AND ", clauses) : ""; + cmd.CommandText = $"SELECT * FROM dead_ends {where} ORDER BY detected_at DESC LIMIT @limit OFFSET @offset"; + cmd.Parameters.AddWithValue("@limit", filter.Limit); + cmd.Parameters.AddWithValue("@offset", filter.Offset); + + return await ReadDeadEnds(cmd); + } + + public async Task> FindByFiles(IReadOnlyList filePaths) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM dead_ends ORDER BY detected_at DESC"; + + var all = await ReadDeadEnds(cmd); + var fileSet = filePaths.ToHashSet(StringComparer.OrdinalIgnoreCase); + + return all + .Where(de => de.FilesInvolved.Any(f => fileSet.Contains(f))) + .ToList(); + } + + public async Task> FindSimilar(string description, int limit = 5) + { + var keywords = description.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(w => w.Length > 3) + .Take(5) + .ToList(); + + if (keywords.Count == 0) + return []; + + using var cmd = _connection.CreateCommand(); + var likeClause = string.Join(" OR ", keywords.Select((_, i) => $"description LIKE @kw{i}")); + cmd.CommandText = $"SELECT * FROM dead_ends WHERE {likeClause} ORDER BY detected_at DESC LIMIT @limit"; + + for (int i = 0; i < keywords.Count; i++) + cmd.Parameters.AddWithValue($"@kw{i}", $"%{keywords[i]}%"); + cmd.Parameters.AddWithValue("@limit", limit); + + return await ReadDeadEnds(cmd); + } + + private static async Task> ReadDeadEnds(SqliteCommand cmd) + { + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(MapDeadEnd(reader)); + } + return results; + } + + private static DeadEnd MapDeadEnd(SqliteDataReader reader) => new() + { + Id = reader.GetString(reader.GetOrdinal("id")), + ThreadId = reader.IsDBNull(reader.GetOrdinal("thread_id")) ? null : reader.GetString(reader.GetOrdinal("thread_id")), + Project = reader.GetString(reader.GetOrdinal("project")), + Description = reader.GetString(reader.GetOrdinal("description")), + Approach = reader.GetString(reader.GetOrdinal("approach")), + Reason = reader.GetString(reader.GetOrdinal("reason")), + FilesInvolved = reader.IsDBNull(reader.GetOrdinal("files_involved")) + ? [] + : JsonSerializer.Deserialize>(reader.GetString(reader.GetOrdinal("files_involved"))) ?? [], + DetectedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("detected_at")), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; +} diff --git a/src/DevBrain.Storage/SqliteGraphStore.cs b/src/DevBrain.Storage/SqliteGraphStore.cs index ab8d2a8..bc4db44 100644 --- a/src/DevBrain.Storage/SqliteGraphStore.cs +++ b/src/DevBrain.Storage/SqliteGraphStore.cs @@ -167,6 +167,50 @@ FROM neighbors nb return nodes; } + public async Task> GetNeighbors(string nodeId, int hops, IReadOnlyList edgeTypes) + { + if (edgeTypes.Count == 0) + return await GetNeighbors(nodeId, hops); + + var edgeParams = new List(); + for (int i = 0; i < edgeTypes.Count; i++) + edgeParams.Add($"@et{i}"); + var inClause = $"AND e.type IN ({string.Join(", ", edgeParams)})"; + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $@" + WITH RECURSIVE neighbors(node_id, depth, path) AS ( + SELECT @startId, 0, @startId + UNION + SELECT + CASE WHEN e.source_id = n.node_id THEN e.target_id ELSE e.source_id END, + n.depth + 1, + n.path || ',' || CASE WHEN e.source_id = n.node_id THEN e.target_id ELSE e.source_id END + FROM neighbors n + JOIN graph_edges e ON (e.source_id = n.node_id OR e.target_id = n.node_id) + {inClause} + WHERE n.depth < @hops + AND instr(n.path, CASE WHEN e.source_id = n.node_id THEN e.target_id ELSE e.source_id END) = 0 + ) + SELECT DISTINCT gn.id, gn.type, gn.name, gn.data, gn.source_id, gn.created_at + FROM neighbors nb + JOIN graph_nodes gn ON gn.id = nb.node_id + WHERE nb.node_id != @startId"; + + cmd.Parameters.AddWithValue("@startId", nodeId); + cmd.Parameters.AddWithValue("@hops", hops); + for (int i = 0; i < edgeTypes.Count; i++) + cmd.Parameters.AddWithValue($"@et{i}", edgeTypes[i]); + + var nodes = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + nodes.Add(ReadNode(reader)); + } + return nodes; + } + public async Task> FindPaths(string fromId, string toId, int maxDepth = 4) { // Recursive CTE to find all paths (directional: source→target only) diff --git a/src/DevBrain.Storage/SqliteObservationStore.cs b/src/DevBrain.Storage/SqliteObservationStore.cs index 408ad6e..eefebe9 100644 --- a/src/DevBrain.Storage/SqliteObservationStore.cs +++ b/src/DevBrain.Storage/SqliteObservationStore.cs @@ -192,6 +192,20 @@ public async Task DeleteBefore(DateTime before) await cmd.ExecuteNonQueryAsync(); } + public async Task> GetSessionObservations(string sessionId, int limit = 500) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM observations WHERE session_id = @sessionId ORDER BY timestamp ASC LIMIT @limit"; + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@limit", limit); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapObservation(reader)); + return results; + } + private static Observation MapObservation(SqliteDataReader reader) => new() { Id = reader.GetString(reader.GetOrdinal("id")), diff --git a/tests/DevBrain.Storage.Tests/SqliteDeadEndStoreTests.cs b/tests/DevBrain.Storage.Tests/SqliteDeadEndStoreTests.cs new file mode 100644 index 0000000..43c7833 --- /dev/null +++ b/tests/DevBrain.Storage.Tests/SqliteDeadEndStoreTests.cs @@ -0,0 +1,112 @@ +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class SqliteDeadEndStoreTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteDeadEndStore _store = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _store = new SqliteDeadEndStore(_connection); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task Add_And_Query_RoundTrips() + { + var deadEnd = new DeadEnd + { + Id = "de-1", + Project = "test-project", + Description = "SQLite FTS doesn't support CJK", + Approach = "Tried FTS5 with default tokenizer", + Reason = "Default tokenizer can't segment CJK characters", + FilesInvolved = ["src/Search.cs", "src/Index.cs"], + DetectedAt = DateTime.UtcNow + }; + + await _store.Add(deadEnd); + + var results = await _store.Query(new DeadEndFilter { Project = "test-project" }); + Assert.Single(results); + Assert.Equal("de-1", results[0].Id); + Assert.Equal("SQLite FTS doesn't support CJK", results[0].Description); + Assert.Equal(2, results[0].FilesInvolved.Count); + } + + [Fact] + public async Task FindByFiles_MatchesOverlappingFiles() + { + var de1 = new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Dead end 1", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs", "src/B.cs"], DetectedAt = DateTime.UtcNow + }; + var de2 = new DeadEnd + { + Id = "de-2", Project = "proj", + Description = "Dead end 2", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/C.cs", "src/D.cs"], DetectedAt = DateTime.UtcNow + }; + + await _store.Add(de1); + await _store.Add(de2); + + var results = await _store.FindByFiles(["src/A.cs", "src/X.cs"]); + Assert.Single(results); + Assert.Equal("de-1", results[0].Id); + } + + [Fact] + public async Task FindByFiles_ReturnsEmptyWhenNoMatch() + { + var de = new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Dead end", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs"], DetectedAt = DateTime.UtcNow + }; + await _store.Add(de); + + var results = await _store.FindByFiles(["src/Z.cs"]); + Assert.Empty(results); + } + + [Fact] + public async Task Query_FiltersByDateRange() + { + var old = new DeadEnd + { + Id = "de-old", Project = "proj", + Description = "Old dead end", Approach = "approach", Reason = "reason", + FilesInvolved = [], DetectedAt = DateTime.UtcNow.AddDays(-10) + }; + var recent = new DeadEnd + { + Id = "de-recent", Project = "proj", + Description = "Recent dead end", Approach = "approach", Reason = "reason", + FilesInvolved = [], DetectedAt = DateTime.UtcNow + }; + + await _store.Add(old); + await _store.Add(recent); + + var results = await _store.Query(new DeadEndFilter { After = DateTime.UtcNow.AddDays(-1) }); + Assert.Single(results); + Assert.Equal("de-recent", results[0].Id); + } +} diff --git a/tests/DevBrain.Storage.Tests/SqliteGraphStoreMultiEdgeTests.cs b/tests/DevBrain.Storage.Tests/SqliteGraphStoreMultiEdgeTests.cs new file mode 100644 index 0000000..c4dac61 --- /dev/null +++ b/tests/DevBrain.Storage.Tests/SqliteGraphStoreMultiEdgeTests.cs @@ -0,0 +1,75 @@ +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class SqliteGraphStoreMultiEdgeTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteGraphStore _store = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _store = new SqliteGraphStore(_connection); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task GetNeighbors_MultiEdgeTypes_FiltersCorrectly() + { + var a = await _store.AddNode("Decision", "A"); + var b = await _store.AddNode("Decision", "B"); + var c = await _store.AddNode("Decision", "C"); + var d = await _store.AddNode("Decision", "D"); + + await _store.AddEdge(a.Id, b.Id, "caused_by"); + await _store.AddEdge(a.Id, c.Id, "supersedes"); + await _store.AddEdge(a.Id, d.Id, "references"); + + var neighbors = await _store.GetNeighbors(a.Id, hops: 1, edgeTypes: ["caused_by", "supersedes"]); + + Assert.Equal(2, neighbors.Count); + var names = neighbors.Select(n => n.Name).OrderBy(n => n).ToList(); + Assert.Equal(new[] { "B", "C" }, names); + } + + [Fact] + public async Task GetNeighbors_MultiEdgeTypes_MultiHop() + { + var a = await _store.AddNode("Decision", "A"); + var b = await _store.AddNode("Decision", "B"); + var c = await _store.AddNode("Decision", "C"); + + await _store.AddEdge(a.Id, b.Id, "caused_by"); + await _store.AddEdge(b.Id, c.Id, "resolved_by"); + + var neighbors = await _store.GetNeighbors(a.Id, hops: 2, edgeTypes: ["caused_by", "resolved_by"]); + + Assert.Equal(2, neighbors.Count); + var names = neighbors.Select(n => n.Name).OrderBy(n => n).ToList(); + Assert.Equal(new[] { "B", "C" }, names); + } + + [Fact] + public async Task GetNeighbors_MultiEdgeTypes_EmptyList_ReturnsAll() + { + var a = await _store.AddNode("Decision", "A"); + var b = await _store.AddNode("Decision", "B"); + var c = await _store.AddNode("Decision", "C"); + + await _store.AddEdge(a.Id, b.Id, "caused_by"); + await _store.AddEdge(a.Id, c.Id, "references"); + + var neighbors = await _store.GetNeighbors(a.Id, hops: 1, edgeTypes: []); + + Assert.Equal(2, neighbors.Count); + } +} From f3381e22ebdfddf288919a7a6b76d63de98f15e7 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:36:58 -0700 Subject: [PATCH 04/36] fix(agents): real cron parsing, dead-end persistence, prompt migration - Replace stub IsCronDue with Cronos-based cron expression parsing - Persist DeadEndDetected outputs via IDeadEndStore in AgentScheduler - Migrate BriefingAgent and CompressionAgent to use Prompts.cs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/AgentScheduler.cs | 46 +++++++++++++++++++++++-- src/DevBrain.Agents/BriefingAgent.cs | 11 ++---- src/DevBrain.Agents/CompressionAgent.cs | 3 +- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/DevBrain.Agents/AgentScheduler.cs b/src/DevBrain.Agents/AgentScheduler.cs index ad52472..dac2f1b 100644 --- a/src/DevBrain.Agents/AgentScheduler.cs +++ b/src/DevBrain.Agents/AgentScheduler.cs @@ -1,6 +1,8 @@ namespace DevBrain.Agents; using System.Collections.Concurrent; +using System.Text.Json; +using Cronos; using DevBrain.Core.Enums; using DevBrain.Core.Interfaces; using DevBrain.Core.Models; @@ -74,7 +76,7 @@ private async Task DispatchAgents(CancellationToken ct) { AgentSchedule.OnEvent onEvent => bufferedEvents.Count > 0 && onEvent.Types.Any(t => bufferedEventTypes.Contains(t)), - AgentSchedule.Cron => IsCronDue(agent.Name), + AgentSchedule.Cron cron => IsCronDue(agent.Name, cron.Expression), AgentSchedule.Idle idle => IsIdle(idle.After), _ => false }; @@ -89,11 +91,13 @@ private async Task DispatchAgents(CancellationToken ct) await Task.WhenAll(tasks); } - private bool IsCronDue(string agentName) + private bool IsCronDue(string agentName, string cronExpression) { if (_lastRunTimes.TryGetValue(agentName, out var lastRun)) { - return (DateTime.UtcNow - lastRun).TotalHours >= 1; + var expression = CronExpression.Parse(cronExpression); + var nextOccurrence = expression.GetNextOccurrence(lastRun, TimeZoneInfo.Utc); + return nextOccurrence.HasValue && nextOccurrence.Value <= DateTime.UtcNow; } return true; } @@ -116,6 +120,42 @@ private async Task RunAgentWithThrottle(IIntelligenceAgent agent, CancellationTo _logger.LogInformation("Agent {AgentName} completed with {Count} outputs", agent.Name, results.Count); + + // Persist dead-end outputs + foreach (var output in results) + { + if (output.Type == AgentOutputType.DeadEndDetected && output.Data is not null) + { + try + { + var data = JsonSerializer.Deserialize( + JsonSerializer.Serialize(output.Data)); + + var threadId = data.TryGetProperty("ThreadId", out var tid) ? tid.GetString() : null; + var files = data.TryGetProperty("Files", out var f) + ? f.EnumerateArray().Select(x => x.GetString()!).ToList() + : new List(); + + var deadEnd = new DeadEnd + { + Id = Guid.NewGuid().ToString(), + ThreadId = threadId, + Project = "unknown", + Description = output.Content, + Approach = "Repeated file edits after errors", + Reason = "Heuristic: 3+ edits to same file in thread with errors", + FilesInvolved = files, + DetectedAt = DateTime.UtcNow + }; + + await _ctx.DeadEnds.Add(deadEnd); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to persist dead-end output"); + } + } + } } catch (Exception ex) { diff --git a/src/DevBrain.Agents/BriefingAgent.cs b/src/DevBrain.Agents/BriefingAgent.cs index 2639664..614a8a3 100644 --- a/src/DevBrain.Agents/BriefingAgent.cs +++ b/src/DevBrain.Agents/BriefingAgent.cs @@ -69,14 +69,7 @@ public async Task> Run(AgentContext ctx, Cancellation private static string BuildPrompt(IReadOnlyList observations) { - var lines = new List - { - "Generate a daily development briefing based on the following observations from the last 24 hours.", - "Summarize key decisions, errors encountered, files changed, and overall progress.", - "Format as markdown with sections.", - "", - "Observations:" - }; + var lines = new List(); foreach (var obs in observations) { @@ -85,6 +78,6 @@ private static string BuildPrompt(IReadOnlyList observations) lines.Add($" Files: {string.Join(", ", obs.FilesInvolved)}"); } - return string.Join("\n", lines); + return string.Format(Prompts.BriefingGeneration, string.Join("\n", lines)); } } diff --git a/src/DevBrain.Agents/CompressionAgent.cs b/src/DevBrain.Agents/CompressionAgent.cs index 9757a15..d6235e2 100644 --- a/src/DevBrain.Agents/CompressionAgent.cs +++ b/src/DevBrain.Agents/CompressionAgent.cs @@ -1,5 +1,6 @@ namespace DevBrain.Agents; +using DevBrain.Core; using DevBrain.Core.Enums; using DevBrain.Core.Interfaces; using DevBrain.Core.Models; @@ -28,7 +29,7 @@ public async Task> Run(AgentContext ctx, Cancellation AgentName = Name, Priority = Priority.Low, Type = LlmTaskType.Summarization, - Prompt = $"Summarize the following development observation concisely:\n\n{obs.RawContent}", + Prompt = string.Format(Prompts.CompressionSummarization, obs.RawContent), Preference = LlmPreference.PreferLocal }; From 42b4610d4e9c369e66d555645d1ead6dca4544a9 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:40:31 -0700 Subject: [PATCH 05/36] feat: wire infrastructure, implement DecisionChainAgent - Wire IDeadEndStore and DecisionChainAgent in Program.cs - Implement DecisionChainAgent with LLM-powered causal edge classification - Update LinkerAgentTests for new AgentContext shape - Add DecisionChainAgent tests with mock LLM service - All 32 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/DecisionChainAgent.cs | 146 +++++++++++++++++ src/DevBrain.Api/Program.cs | 7 +- .../DecisionChainAgentTests.cs | 150 ++++++++++++++++++ .../DevBrain.Agents.Tests/LinkerAgentTests.cs | 12 ++ 4 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/DevBrain.Agents/DecisionChainAgent.cs create mode 100644 tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs diff --git a/src/DevBrain.Agents/DecisionChainAgent.cs b/src/DevBrain.Agents/DecisionChainAgent.cs new file mode 100644 index 0000000..68c4365 --- /dev/null +++ b/src/DevBrain.Agents/DecisionChainAgent.cs @@ -0,0 +1,146 @@ +namespace DevBrain.Agents; + +using DevBrain.Core; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class DecisionChainAgent : IIntelligenceAgent +{ + public string Name => "decision-chain"; + + public AgentSchedule Schedule => new AgentSchedule.OnEvent(EventType.Decision); + + public Priority Priority => Priority.High; + + private static readonly HashSet CausalEdgeTypes = ["caused_by", "supersedes", "resolved_by"]; + + public async Task> Run(AgentContext ctx, CancellationToken ct) + { + var outputs = new List(); + + var recentDecisions = await ctx.Observations.Query(new ObservationFilter + { + EventType = EventType.Decision, + After = DateTime.UtcNow.AddMinutes(-10), + Limit = 20 + }); + + foreach (var decision in recentDecisions) + { + if (ct.IsCancellationRequested) break; + if (decision.FilesInvolved.Count == 0) continue; + + var decisionNode = await FindOrCreateDecisionNode(ctx, decision); + var candidates = await FindCandidateNodes(ctx, decision); + + foreach (var candidate in candidates) + { + if (candidate.Id == decisionNode.Id) continue; + if (candidate.SourceId == decision.Id) continue; + + var existingNeighbors = await ctx.Graph.GetNeighbors( + decisionNode.Id, hops: 1, edgeTypes: CausalEdgeTypes.ToList()); + if (existingNeighbors.Any(n => n.Id == candidate.Id)) continue; + + var edgeType = await ClassifyRelationship(ctx, decision, candidate, ct); + if (edgeType is null) continue; + + await ctx.Graph.AddEdge(decisionNode.Id, candidate.Id, edgeType); + + outputs.Add(new AgentOutput( + AgentOutputType.DecisionChainBuilt, + $"Linked '{decisionNode.Name}' --{edgeType}--> '{candidate.Name}'")); + } + + var resolvedDeadEnds = await CheckDeadEndResolution(ctx, decision, decisionNode, ct); + outputs.AddRange(resolvedDeadEnds); + } + + return outputs; + } + + private static async Task FindOrCreateDecisionNode(AgentContext ctx, Observation decision) + { + var existing = await ctx.Graph.GetNodesByType("Decision"); + var found = existing.FirstOrDefault(n => n.SourceId == decision.Id); + if (found is not null) return found; + + return await ctx.Graph.AddNode("Decision", decision.Summary ?? decision.RawContent, sourceId: decision.Id); + } + + private static async Task> FindCandidateNodes(AgentContext ctx, Observation decision) + { + var candidates = new List(); + foreach (var file in decision.FilesInvolved) + { + var related = await ctx.Graph.GetRelatedToFile(file); + foreach (var node in related) + { + if (node.Type is "Decision" or "Bug" && !candidates.Any(c => c.Id == node.Id)) + candidates.Add(node); + } + } + return candidates; + } + + private static async Task ClassifyRelationship( + AgentContext ctx, Observation decision, GraphNode candidate, CancellationToken ct) + { + var prompt = string.Format(Prompts.DecisionClassification, + candidate.Name, + decision.Summary ?? decision.RawContent, + string.Join(", ", decision.FilesInvolved)); + + var task = new LlmTask + { + AgentName = "decision-chain", + Priority = Priority.High, + Type = LlmTaskType.Classification, + Prompt = prompt, + Preference = LlmPreference.PreferLocal + }; + + LlmResult result; + try + { + result = await ctx.Llm.Submit(task, ct); + } + catch + { + return null; + } + + if (!result.Success || string.IsNullOrEmpty(result.Content)) + return null; + + var label = result.Content.Trim().ToLowerInvariant(); + return CausalEdgeTypes.Contains(label) ? label : null; + } + + private static async Task> CheckDeadEndResolution( + AgentContext ctx, Observation decision, GraphNode decisionNode, CancellationToken ct) + { + var outputs = new List(); + + var matchingDeadEnds = await ctx.DeadEnds.FindByFiles(decision.FilesInvolved); + foreach (var deadEnd in matchingDeadEnds) + { + var deNodes = await ctx.Graph.GetNodesByType("Bug"); + var deNode = deNodes.FirstOrDefault(n => n.SourceId == deadEnd.Id); + if (deNode is null) + deNode = await ctx.Graph.AddNode("Bug", deadEnd.Description, sourceId: deadEnd.Id); + + var neighbors = await ctx.Graph.GetNeighbors(deNode.Id, hops: 1, edgeTypes: ["resolved_by"]); + if (neighbors.Any(n => n.Id == decisionNode.Id)) continue; + + await ctx.Graph.AddEdge(decisionNode.Id, deNode.Id, "resolved_by"); + + outputs.Add(new AgentOutput( + AgentOutputType.DecisionChainBuilt, + $"Decision '{decisionNode.Name}' may resolve dead end: '{deadEnd.Description}'")); + } + + return outputs; + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 3cef839..47041f5 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -37,6 +37,7 @@ var observationStore = new SqliteObservationStore(connection); var graphStore = new SqliteGraphStore(connection); +var deadEndStore = new SqliteDeadEndStore(connection); // ── Vector store (placeholder) ─────────────────────────────────────────────── var vectorStore = new NullVectorStore(); @@ -75,13 +76,14 @@ var pipeline = new PipelineOrchestrator(normalizer, enricher, tagger, privacyFilter, writer); // ── Agents ─────────────────────────────────────────────────────────────────── -var agentContext = new AgentContext(observationStore, graphStore, vectorStore, llmService, settings); +var agentContext = new AgentContext(observationStore, graphStore, vectorStore, llmService, deadEndStore, settings); var agents = new IIntelligenceAgent[] { new LinkerAgent(), new DeadEndAgent(), new BriefingAgent(), - new CompressionAgent() + new CompressionAgent(), + new DecisionChainAgent() }; // ── ASP.NET Core host ──────────────────────────────────────────────────────── @@ -101,6 +103,7 @@ builder.Services.AddSingleton(observationStore); builder.Services.AddSingleton(graphStore); builder.Services.AddSingleton(vectorStore); +builder.Services.AddSingleton(deadEndStore); builder.Services.AddSingleton(llmService); builder.Services.AddSingleton(llmService); // concrete type for ResetDailyCounter builder.Services.AddSingleton(eventBus); diff --git a/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs b/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs new file mode 100644 index 0000000..8874cf4 --- /dev/null +++ b/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs @@ -0,0 +1,150 @@ +using DevBrain.Agents; +using DevBrain.Core; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Agents.Tests; + +public class DecisionChainAgentTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private SqliteDeadEndStore _deadEndStore = null!; + private DecisionChainAgent _agent = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _deadEndStore = new SqliteDeadEndStore(_connection); + _agent = new DecisionChainAgent(); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + private AgentContext CreateContext(ILlmService? llm = null) + { + return new AgentContext( + Observations: _obsStore, + Graph: _graphStore, + Vectors: new NullVectorStore(), + Llm: llm ?? new ClassifyingLlmService("caused_by"), + DeadEnds: _deadEndStore, + Settings: new Settings() + ); + } + + [Fact] + public async Task Run_CreatesEdgeBetweenRelatedDecisions() + { + var obs1 = new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow.AddMinutes(-5), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Decided to use SQLite", Summary = "Use SQLite for storage", + FilesInvolved = ["src/Storage.cs"] + }; + var obs2 = new Observation + { + Id = "obs-2", SessionId = "s1", Timestamp = DateTime.UtcNow, + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Added WAL mode", Summary = "Enable WAL mode for concurrency", + FilesInvolved = ["src/Storage.cs"] + }; + await _obsStore.Add(obs1); + await _obsStore.Add(obs2); + + // Pre-create the LinkerAgent's node for obs1 + await _graphStore.AddNode("Decision", "Use SQLite for storage", sourceId: "obs-1"); + // Also create the File node so GetRelatedToFile works + var fileNode = await _graphStore.AddNode("File", "src/Storage.cs"); + var decNode = (await _graphStore.GetNodesByType("Decision"))[0]; + await _graphStore.AddEdge(decNode.Id, fileNode.Id, "references"); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Contains(results, r => r.Type == AgentOutputType.DecisionChainBuilt); + + var decisionNodes = await _graphStore.GetNodesByType("Decision"); + Assert.True(decisionNodes.Count >= 2); + + var newNode = decisionNodes.FirstOrDefault(n => n.SourceId == "obs-2"); + Assert.NotNull(newNode); + + var neighbors = await _graphStore.GetNeighbors(newNode!.Id, hops: 1, edgeTypes: ["caused_by"]); + Assert.Single(neighbors); + } + + [Fact] + public async Task Run_SkipsWhenLlmClassifiesAsUnrelated() + { + var obs1 = new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow.AddMinutes(-5), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Use Redis for cache", Summary = "Redis caching", + FilesInvolved = ["src/Cache.cs"] + }; + var obs2 = new Observation + { + Id = "obs-2", SessionId = "s1", Timestamp = DateTime.UtcNow, + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Add logging", Summary = "Structured logging", + FilesInvolved = ["src/Cache.cs"] + }; + await _obsStore.Add(obs1); + await _obsStore.Add(obs2); + + await _graphStore.AddNode("Decision", "Redis caching", sourceId: "obs-1"); + var fileNode = await _graphStore.AddNode("File", "src/Cache.cs"); + var decNode = (await _graphStore.GetNodesByType("Decision"))[0]; + await _graphStore.AddEdge(decNode.Id, fileNode.Id, "references"); + + var ctx = CreateContext(new ClassifyingLlmService("unrelated")); + var results = await _agent.Run(ctx, CancellationToken.None); + + var decisionNodes = await _graphStore.GetNodesByType("Decision"); + var newNode = decisionNodes.FirstOrDefault(n => n.SourceId == "obs-2"); + if (newNode is not null) + { + var neighbors = await _graphStore.GetNeighbors(newNode.Id, hops: 1, edgeTypes: ["caused_by", "supersedes", "resolved_by"]); + Assert.Empty(neighbors); + } + } + + private class ClassifyingLlmService : ILlmService + { + private readonly string _response; + public ClassifyingLlmService(string response) => _response = response; + public bool IsLocalAvailable => true; + public bool IsCloudAvailable => false; + public int CloudRequestsToday => 0; + public int QueueDepth => 0; + public Task Submit(LlmTask task, CancellationToken ct = default) + => Task.FromResult(new LlmResult { TaskId = task.Id, Success = true, Content = _response }); + public Task Embed(string text, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + } + + private class NullVectorStore : IVectorStore + { + public Task Index(string id, string text, VectorCategory category) => Task.CompletedTask; + public Task> Search(string query, int topK = 20, VectorCategory? filter = null) + => Task.FromResult>(Array.Empty()); + public Task Remove(string id) => Task.CompletedTask; + public Task Rebuild() => Task.CompletedTask; + public Task GetSizeBytes() => Task.FromResult(0L); + } +} diff --git a/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs b/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs index f3fb4bf..f3d5eac 100644 --- a/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs @@ -37,6 +37,7 @@ private AgentContext CreateContext() Graph: _graphStore, Vectors: new NullVectorStore(), Llm: new NullLlmService(), + DeadEnds: new NullDeadEndStore(), Settings: new Settings() ); } @@ -164,6 +165,17 @@ public Task> Search(string query, int topK = 20, Vect public Task GetSizeBytes() => Task.FromResult(0L); } + private class NullDeadEndStore : IDeadEndStore + { + public Task Add(DeadEnd deadEnd) => Task.FromResult(deadEnd); + public Task> Query(DeadEndFilter filter) + => Task.FromResult>(Array.Empty()); + public Task> FindByFiles(IReadOnlyList filePaths) + => Task.FromResult>(Array.Empty()); + public Task> FindSimilar(string description, int limit = 5) + => Task.FromResult>(Array.Empty()); + } + private class NullLlmService : ILlmService { public bool IsLocalAvailable => false; From aa418511bee9e144ede62747328ab5b7f532612f Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:16:37 -0700 Subject: [PATCH 06/36] fix: address code review findings across foundation Critical fixes: - Replace string.Format with Prompts.Fill() using named placeholders to prevent FormatException on content with braces - Replace fragile JSON round-trip with concrete DeadEndOutputData DTO in AgentScheduler dead-end persistence - Add SQL LIKE pre-filter in FindByFiles to avoid full-table scan High priority fixes: - Add IGraphStore.GetNodeBySourceId for O(1) indexed lookups, replacing O(N) GetNodesByType scans in DecisionChainAgent - Move IDeadEndStore to end of AgentContext parameter list to preserve backward compatibility for positional constructors - Re-throw OperationCanceledException in DecisionChainAgent to support clean shutdown Medium fixes: - Extract project from observations in dead-end persistence (was hardcoded "unknown") - Extract shared NullVectorStore/NullLlmService/NullDeadEndStore into TestHelpers.cs to eliminate duplication - Lower FindSimilar keyword minimum length from 4 to 3 chars All 55 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/AgentScheduler.cs | 17 ++--- src/DevBrain.Agents/BriefingAgent.cs | 2 +- src/DevBrain.Agents/CompressionAgent.cs | 2 +- src/DevBrain.Agents/DeadEndAgent.cs | 2 +- src/DevBrain.Agents/DecisionChainAgent.cs | 18 ++--- src/DevBrain.Api/Program.cs | 2 +- src/DevBrain.Core/Interfaces/IGraphStore.cs | 1 + .../Interfaces/IIntelligenceAgent.cs | 4 +- src/DevBrain.Core/Models/AgentOutput.cs | 2 + src/DevBrain.Core/Prompts.cs | 65 +++++++++++-------- src/DevBrain.Storage/SqliteDeadEndStore.cs | 26 ++++++-- src/DevBrain.Storage/SqliteGraphStore.cs | 12 ++++ .../DecisionChainAgentTests.cs | 13 +--- .../DevBrain.Agents.Tests/LinkerAgentTests.cs | 42 +----------- tests/DevBrain.Agents.Tests/TestHelpers.cs | 38 +++++++++++ 15 files changed, 138 insertions(+), 108 deletions(-) create mode 100644 tests/DevBrain.Agents.Tests/TestHelpers.cs diff --git a/src/DevBrain.Agents/AgentScheduler.cs b/src/DevBrain.Agents/AgentScheduler.cs index dac2f1b..1bee285 100644 --- a/src/DevBrain.Agents/AgentScheduler.cs +++ b/src/DevBrain.Agents/AgentScheduler.cs @@ -1,7 +1,6 @@ namespace DevBrain.Agents; using System.Collections.Concurrent; -using System.Text.Json; using Cronos; using DevBrain.Core.Enums; using DevBrain.Core.Interfaces; @@ -124,27 +123,19 @@ private async Task RunAgentWithThrottle(IIntelligenceAgent agent, CancellationTo // Persist dead-end outputs foreach (var output in results) { - if (output.Type == AgentOutputType.DeadEndDetected && output.Data is not null) + if (output.Type == AgentOutputType.DeadEndDetected && output.Data is DeadEndOutputData data) { try { - var data = JsonSerializer.Deserialize( - JsonSerializer.Serialize(output.Data)); - - var threadId = data.TryGetProperty("ThreadId", out var tid) ? tid.GetString() : null; - var files = data.TryGetProperty("Files", out var f) - ? f.EnumerateArray().Select(x => x.GetString()!).ToList() - : new List(); - var deadEnd = new DeadEnd { Id = Guid.NewGuid().ToString(), - ThreadId = threadId, - Project = "unknown", + ThreadId = data.ThreadId, + Project = data.Project, Description = output.Content, Approach = "Repeated file edits after errors", Reason = "Heuristic: 3+ edits to same file in thread with errors", - FilesInvolved = files, + FilesInvolved = data.Files, DetectedAt = DateTime.UtcNow }; diff --git a/src/DevBrain.Agents/BriefingAgent.cs b/src/DevBrain.Agents/BriefingAgent.cs index 614a8a3..4f95c65 100644 --- a/src/DevBrain.Agents/BriefingAgent.cs +++ b/src/DevBrain.Agents/BriefingAgent.cs @@ -78,6 +78,6 @@ private static string BuildPrompt(IReadOnlyList observations) lines.Add($" Files: {string.Join(", ", obs.FilesInvolved)}"); } - return string.Format(Prompts.BriefingGeneration, string.Join("\n", lines)); + return Prompts.Fill(Prompts.BriefingGeneration, ("OBSERVATIONS", string.Join("\n", lines))); } } diff --git a/src/DevBrain.Agents/CompressionAgent.cs b/src/DevBrain.Agents/CompressionAgent.cs index d6235e2..f82e2f7 100644 --- a/src/DevBrain.Agents/CompressionAgent.cs +++ b/src/DevBrain.Agents/CompressionAgent.cs @@ -29,7 +29,7 @@ public async Task> Run(AgentContext ctx, Cancellation AgentName = Name, Priority = Priority.Low, Type = LlmTaskType.Summarization, - Prompt = string.Format(Prompts.CompressionSummarization, obs.RawContent), + Prompt = Prompts.Fill(Prompts.CompressionSummarization, ("CONTENT", obs.RawContent)), Preference = LlmPreference.PreferLocal }; diff --git a/src/DevBrain.Agents/DeadEndAgent.cs b/src/DevBrain.Agents/DeadEndAgent.cs index b22893b..589b989 100644 --- a/src/DevBrain.Agents/DeadEndAgent.cs +++ b/src/DevBrain.Agents/DeadEndAgent.cs @@ -51,7 +51,7 @@ public async Task> Run(AgentContext ctx, Cancellation outputs.Add(new AgentOutput( AgentOutputType.DeadEndDetected, description, - new { ThreadId = error.ThreadId, Files = fileChanges })); + new DeadEndOutputData(error.ThreadId, error.Project, fileChanges))); } } diff --git a/src/DevBrain.Agents/DecisionChainAgent.cs b/src/DevBrain.Agents/DecisionChainAgent.cs index 68c4365..929175a 100644 --- a/src/DevBrain.Agents/DecisionChainAgent.cs +++ b/src/DevBrain.Agents/DecisionChainAgent.cs @@ -62,8 +62,7 @@ public async Task> Run(AgentContext ctx, Cancellation private static async Task FindOrCreateDecisionNode(AgentContext ctx, Observation decision) { - var existing = await ctx.Graph.GetNodesByType("Decision"); - var found = existing.FirstOrDefault(n => n.SourceId == decision.Id); + var found = await ctx.Graph.GetNodeBySourceId(decision.Id); if (found is not null) return found; return await ctx.Graph.AddNode("Decision", decision.Summary ?? decision.RawContent, sourceId: decision.Id); @@ -87,10 +86,10 @@ private static async Task> FindCandidateNodes(AgentContext ctx, private static async Task ClassifyRelationship( AgentContext ctx, Observation decision, GraphNode candidate, CancellationToken ct) { - var prompt = string.Format(Prompts.DecisionClassification, - candidate.Name, - decision.Summary ?? decision.RawContent, - string.Join(", ", decision.FilesInvolved)); + var prompt = Prompts.Fill(Prompts.DecisionClassification, + ("DECISION_A", candidate.Name), + ("DECISION_B", decision.Summary ?? decision.RawContent), + ("SHARED_FILES", string.Join(", ", decision.FilesInvolved))); var task = new LlmTask { @@ -106,6 +105,10 @@ private static async Task> FindCandidateNodes(AgentContext ctx, { result = await ctx.Llm.Submit(task, ct); } + catch (OperationCanceledException) + { + throw; + } catch { return null; @@ -126,8 +129,7 @@ private static async Task> CheckDeadEndResolution( var matchingDeadEnds = await ctx.DeadEnds.FindByFiles(decision.FilesInvolved); foreach (var deadEnd in matchingDeadEnds) { - var deNodes = await ctx.Graph.GetNodesByType("Bug"); - var deNode = deNodes.FirstOrDefault(n => n.SourceId == deadEnd.Id); + var deNode = await ctx.Graph.GetNodeBySourceId(deadEnd.Id); if (deNode is null) deNode = await ctx.Graph.AddNode("Bug", deadEnd.Description, sourceId: deadEnd.Id); diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 47041f5..abe9044 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -76,7 +76,7 @@ var pipeline = new PipelineOrchestrator(normalizer, enricher, tagger, privacyFilter, writer); // ── Agents ─────────────────────────────────────────────────────────────────── -var agentContext = new AgentContext(observationStore, graphStore, vectorStore, llmService, deadEndStore, settings); +var agentContext = new AgentContext(observationStore, graphStore, vectorStore, llmService, settings, deadEndStore); var agents = new IIntelligenceAgent[] { new LinkerAgent(), diff --git a/src/DevBrain.Core/Interfaces/IGraphStore.cs b/src/DevBrain.Core/Interfaces/IGraphStore.cs index fe9aa33..f02e847 100644 --- a/src/DevBrain.Core/Interfaces/IGraphStore.cs +++ b/src/DevBrain.Core/Interfaces/IGraphStore.cs @@ -7,6 +7,7 @@ public interface IGraphStore Task AddNode(string type, string name, object? data = null, string? sourceId = null); Task GetNode(string id); Task> GetNodesByType(string type); + Task GetNodeBySourceId(string sourceId); Task RemoveNode(string id); Task AddEdge(string sourceId, string targetId, string type, object? data = null); diff --git a/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs b/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs index 9339ab1..2539153 100644 --- a/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs +++ b/src/DevBrain.Core/Interfaces/IIntelligenceAgent.cs @@ -8,8 +8,8 @@ public record AgentContext( IGraphStore Graph, IVectorStore Vectors, ILlmService Llm, - IDeadEndStore DeadEnds, - Settings Settings + Settings Settings, + IDeadEndStore DeadEnds ); public interface IIntelligenceAgent diff --git a/src/DevBrain.Core/Models/AgentOutput.cs b/src/DevBrain.Core/Models/AgentOutput.cs index 395eee5..64cfd8c 100644 --- a/src/DevBrain.Core/Models/AgentOutput.cs +++ b/src/DevBrain.Core/Models/AgentOutput.cs @@ -15,3 +15,5 @@ public enum AgentOutputType } public record AgentOutput(AgentOutputType Type, string Content, object? Data = null); + +public record DeadEndOutputData(string? ThreadId, string Project, IReadOnlyList Files); diff --git a/src/DevBrain.Core/Prompts.cs b/src/DevBrain.Core/Prompts.cs index 0fad97d..b029634 100644 --- a/src/DevBrain.Core/Prompts.cs +++ b/src/DevBrain.Core/Prompts.cs @@ -2,17 +2,30 @@ namespace DevBrain.Core; public static class Prompts { + /// + /// Safely fills named placeholders in a prompt template. + /// Uses {NAME} syntax instead of {0} to avoid FormatException + /// when user content contains braces (common in C# code). + /// + public static string Fill(string template, params (string key, string value)[] replacements) + { + var result = template; + foreach (var (key, value) in replacements) + result = result.Replace($"{{{key}}}", value); + return result; + } + // -- Feature 2: Session Storytelling -- public const string StorytellerNarrative = """ Write a developer session narrative from these events. - Session duration: {0} - Phases: {1} - Turning points: {2} + Session duration: {DURATION} + Phases: {PHASES} + Turning points: {TURNING_POINTS} Events: - {3} + {EVENTS} Rules: - Past tense, third person ("The developer...") @@ -28,9 +41,9 @@ Write a developer session narrative from these events. public const string DecisionClassification = """ Given two developer decisions about the same codebase, classify their relationship. - Decision A (earlier): {0} - Decision B (later): {1} - Shared files: {2} + Decision A (earlier): {DECISION_A} + Decision B (later): {DECISION_B} + Shared files: {SHARED_FILES} Classify as ONE of: - caused_by: B was motivated by A @@ -44,9 +57,9 @@ Respond with ONLY the classification label. public const string DecisionChainNarrative = """ Explain why this code exists by narrating the chain of decisions that led to it. - File: {0} + File: {FILE} Decision chain (chronological): - {1} + {CHAIN} Rules: - Explain the "why" behind each decision @@ -60,11 +73,11 @@ Decision chain (chronological): public const string BlastRadiusSummary = """ Summarize the potential impact of changing this file. - File being changed: {0} + File being changed: {FILE} Affected files and their connection: - {1} + {AFFECTED} Dead ends at risk of re-triggering: - {2} + {DEAD_ENDS} Rules: - Focus on the highest-risk impacts @@ -83,10 +96,10 @@ Summarize the potential impact of changing this file. 4 = Complex (architectural decisions, novel algorithms) 5 = Expert (system design, performance-critical, multi-system integration) - Thread summary: {0} - Files changed: {1} - Decisions made: {2} - Errors encountered: {3} + Thread summary: {SUMMARY} + Files changed: {FILES} + Decisions made: {DECISIONS} + Errors encountered: {ERRORS} Respond with ONLY the number. """; @@ -100,8 +113,8 @@ Respond with ONLY the number. - environment: config issue, missing dependency, wrong version - external: third-party API failure, network issue, dependency bug - Error: {0} - Context: {1} + Error: {ERROR} + Context: {CONTEXT} Respond with ONLY the category. """; @@ -112,12 +125,12 @@ Respond with ONLY the category. Given these developer metrics for the past week, write 2-3 sentences highlighting the most interesting trend or achievement. - Metrics: {0} - Milestones: {1} - 4-week trend: {2} - Complexity trend: {3} - Quality trend: {4} - Error breakdown: {5} + Metrics: {METRICS} + Milestones: {MILESTONES} + 4-week trend: {TREND} + Complexity trend: {COMPLEXITY} + Quality trend: {QUALITY} + Error breakdown: {ERROR_BREAKDOWN} Rules: - Be encouraging but honest @@ -135,7 +148,7 @@ Generate a daily development briefing based on the following observations from t Format as markdown with sections. Observations: - {0} + {OBSERVATIONS} """; // -- Existing: Compression Agent (migrated from CompressionAgent.cs) -- @@ -143,6 +156,6 @@ Format as markdown with sections. public const string CompressionSummarization = """ Summarize the following development observation concisely: - {0} + {CONTENT} """; } diff --git a/src/DevBrain.Storage/SqliteDeadEndStore.cs b/src/DevBrain.Storage/SqliteDeadEndStore.cs index 2df16c8..dc29849 100644 --- a/src/DevBrain.Storage/SqliteDeadEndStore.cs +++ b/src/DevBrain.Storage/SqliteDeadEndStore.cs @@ -75,21 +75,39 @@ public async Task> Query(DeadEndFilter filter) public async Task> FindByFiles(IReadOnlyList filePaths) { + if (filePaths.Count == 0) + return []; + + // Pre-filter in SQL using LIKE on the JSON text column, then verify client-side. + // This avoids a full-table scan while handling JSON array storage correctly. using var cmd = _connection.CreateCommand(); - cmd.CommandText = "SELECT * FROM dead_ends ORDER BY detected_at DESC"; + var likeClauses = new List(); + for (int i = 0; i < filePaths.Count; i++) + { + likeClauses.Add($"files_involved LIKE @fp{i}"); + cmd.Parameters.AddWithValue($"@fp{i}", $"%{EscapeLike(filePaths[i])}%"); + } - var all = await ReadDeadEnds(cmd); + cmd.CommandText = $"SELECT * FROM dead_ends WHERE {string.Join(" OR ", likeClauses)} ORDER BY detected_at DESC LIMIT 200"; + + var candidates = await ReadDeadEnds(cmd); var fileSet = filePaths.ToHashSet(StringComparer.OrdinalIgnoreCase); - return all + // Exact match after JSON deserialization (LIKE may produce false positives) + return candidates .Where(de => de.FilesInvolved.Any(f => fileSet.Contains(f))) .ToList(); } + private static string EscapeLike(string value) + { + return value.Replace("%", "").Replace("_", "").Replace("[", ""); + } + public async Task> FindSimilar(string description, int limit = 5) { var keywords = description.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Where(w => w.Length > 3) + .Where(w => w.Length > 2) .Take(5) .ToList(); diff --git a/src/DevBrain.Storage/SqliteGraphStore.cs b/src/DevBrain.Storage/SqliteGraphStore.cs index bc4db44..19db8fc 100644 --- a/src/DevBrain.Storage/SqliteGraphStore.cs +++ b/src/DevBrain.Storage/SqliteGraphStore.cs @@ -74,6 +74,18 @@ public async Task> GetNodesByType(string type) return nodes; } + public async Task GetNodeBySourceId(string sourceId) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT id, type, name, data, source_id, created_at FROM graph_nodes WHERE source_id = @sourceId LIMIT 1"; + cmd.Parameters.AddWithValue("@sourceId", sourceId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return ReadNode(reader); + return null; + } + public async Task RemoveNode(string id) { // Delete all connected edges first (cascade) diff --git a/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs b/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs index 8874cf4..64b1be2 100644 --- a/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/DecisionChainAgentTests.cs @@ -40,8 +40,8 @@ private AgentContext CreateContext(ILlmService? llm = null) Graph: _graphStore, Vectors: new NullVectorStore(), Llm: llm ?? new ClassifyingLlmService("caused_by"), - DeadEnds: _deadEndStore, - Settings: new Settings() + Settings: new Settings(), + DeadEnds: _deadEndStore ); } @@ -138,13 +138,4 @@ public Task Embed(string text, CancellationToken ct = default) => Task.FromResult(Array.Empty()); } - private class NullVectorStore : IVectorStore - { - public Task Index(string id, string text, VectorCategory category) => Task.CompletedTask; - public Task> Search(string query, int topK = 20, VectorCategory? filter = null) - => Task.FromResult>(Array.Empty()); - public Task Remove(string id) => Task.CompletedTask; - public Task Rebuild() => Task.CompletedTask; - public Task GetSizeBytes() => Task.FromResult(0L); - } } diff --git a/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs b/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs index f3d5eac..2f0156c 100644 --- a/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/LinkerAgentTests.cs @@ -37,8 +37,8 @@ private AgentContext CreateContext() Graph: _graphStore, Vectors: new NullVectorStore(), Llm: new NullLlmService(), - DeadEnds: new NullDeadEndStore(), - Settings: new Settings() + Settings: new Settings(), + DeadEnds: new NullDeadEndStore() ); } @@ -65,13 +65,11 @@ public async Task Run_CreatesFileNodesAndEdgesForObservationsWithFiles() Assert.Equal(2, results.Count); Assert.All(results, r => Assert.Equal(AgentOutputType.EdgeCreated, r.Type)); - // Verify file nodes were created var fileNodes = await _graphStore.GetNodesByType("File"); Assert.Equal(2, fileNodes.Count); Assert.Contains(fileNodes, n => n.Name == "src/Program.cs"); Assert.Contains(fileNodes, n => n.Name == "src/Startup.cs"); - // Verify observation node was created var decisionNodes = await _graphStore.GetNodesByType("Decision"); Assert.Single(decisionNodes); Assert.Equal("Architecture decision", decisionNodes[0].Name); @@ -81,7 +79,6 @@ public async Task Run_CreatesFileNodesAndEdgesForObservationsWithFiles() [Fact] public async Task Run_DoesNotDuplicateExistingFileNodes() { - // Pre-create a file node await _graphStore.AddNode("File", "src/Program.cs"); var obs1 = new Observation @@ -117,12 +114,10 @@ public async Task Run_DoesNotDuplicateExistingFileNodes() Assert.Equal(2, results.Count); - // Verify only one File node exists (no duplicate) var fileNodes = await _graphStore.GetNodesByType("File"); Assert.Single(fileNodes); Assert.Equal("src/Program.cs", fileNodes[0].Name); - // Verify both observation node types were created var decisionNodes = await _graphStore.GetNodesByType("Decision"); Assert.Single(decisionNodes); @@ -154,37 +149,4 @@ public async Task Run_SkipsObservationsWithoutFiles() var fileNodes = await _graphStore.GetNodesByType("File"); Assert.Empty(fileNodes); } - - private class NullVectorStore : IVectorStore - { - public Task Index(string id, string text, VectorCategory category) => Task.CompletedTask; - public Task> Search(string query, int topK = 20, VectorCategory? filter = null) - => Task.FromResult>(Array.Empty()); - public Task Remove(string id) => Task.CompletedTask; - public Task Rebuild() => Task.CompletedTask; - public Task GetSizeBytes() => Task.FromResult(0L); - } - - private class NullDeadEndStore : IDeadEndStore - { - public Task Add(DeadEnd deadEnd) => Task.FromResult(deadEnd); - public Task> Query(DeadEndFilter filter) - => Task.FromResult>(Array.Empty()); - public Task> FindByFiles(IReadOnlyList filePaths) - => Task.FromResult>(Array.Empty()); - public Task> FindSimilar(string description, int limit = 5) - => Task.FromResult>(Array.Empty()); - } - - private class NullLlmService : ILlmService - { - public bool IsLocalAvailable => false; - public bool IsCloudAvailable => false; - public int CloudRequestsToday => 0; - public int QueueDepth => 0; - public Task Submit(LlmTask task, CancellationToken ct = default) - => Task.FromResult(new LlmResult { TaskId = task.Id, Success = false }); - public Task Embed(string text, CancellationToken ct = default) - => Task.FromResult(Array.Empty()); - } } diff --git a/tests/DevBrain.Agents.Tests/TestHelpers.cs b/tests/DevBrain.Agents.Tests/TestHelpers.cs new file mode 100644 index 0000000..5673185 --- /dev/null +++ b/tests/DevBrain.Agents.Tests/TestHelpers.cs @@ -0,0 +1,38 @@ +namespace DevBrain.Agents.Tests; + +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class NullVectorStore : IVectorStore +{ + public Task Index(string id, string text, VectorCategory category) => Task.CompletedTask; + public Task> Search(string query, int topK = 20, VectorCategory? filter = null) + => Task.FromResult>(Array.Empty()); + public Task Remove(string id) => Task.CompletedTask; + public Task Rebuild() => Task.CompletedTask; + public Task GetSizeBytes() => Task.FromResult(0L); +} + +public class NullLlmService : ILlmService +{ + public bool IsLocalAvailable => false; + public bool IsCloudAvailable => false; + public int CloudRequestsToday => 0; + public int QueueDepth => 0; + public Task Submit(LlmTask task, CancellationToken ct = default) + => Task.FromResult(new LlmResult { TaskId = task.Id, Success = false }); + public Task Embed(string text, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); +} + +public class NullDeadEndStore : IDeadEndStore +{ + public Task Add(DeadEnd deadEnd) => Task.FromResult(deadEnd); + public Task> Query(DeadEndFilter filter) + => Task.FromResult>(Array.Empty()); + public Task> FindByFiles(IReadOnlyList filePaths) + => Task.FromResult>(Array.Empty()); + public Task> FindSimilar(string description, int limit = 5) + => Task.FromResult>(Array.Empty()); +} From a6280cf00d97d1080995d7b1627ed6b9e29e33b3 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:33:26 -0700 Subject: [PATCH 07/36] feat: add IAlertStore interface and SqliteAlertStore implementation - IAlertStore with Add, GetActive, GetAll, Dismiss, Exists - SqliteAlertStore persists to deja_vu_alerts table - Dedup via Exists(threadId, deadEndId) ignoring dismissed - 5 tests covering CRUD, dismiss, and dedup Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Core/Interfaces/IAlertStore.cs | 12 ++ src/DevBrain.Storage/SqliteAlertStore.cs | 99 +++++++++++++++ .../SqliteAlertStoreTests.cs | 120 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/DevBrain.Core/Interfaces/IAlertStore.cs create mode 100644 src/DevBrain.Storage/SqliteAlertStore.cs create mode 100644 tests/DevBrain.Storage.Tests/SqliteAlertStoreTests.cs diff --git a/src/DevBrain.Core/Interfaces/IAlertStore.cs b/src/DevBrain.Core/Interfaces/IAlertStore.cs new file mode 100644 index 0000000..18fbf56 --- /dev/null +++ b/src/DevBrain.Core/Interfaces/IAlertStore.cs @@ -0,0 +1,12 @@ +namespace DevBrain.Core.Interfaces; + +using DevBrain.Core.Models; + +public interface IAlertStore +{ + Task Add(DejaVuAlert alert); + Task> GetActive(); + Task> GetAll(int limit = 100); + Task Dismiss(string id); + Task Exists(string threadId, string deadEndId); +} diff --git a/src/DevBrain.Storage/SqliteAlertStore.cs b/src/DevBrain.Storage/SqliteAlertStore.cs new file mode 100644 index 0000000..1cc2a5a --- /dev/null +++ b/src/DevBrain.Storage/SqliteAlertStore.cs @@ -0,0 +1,99 @@ +namespace DevBrain.Storage; + +using System.Globalization; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using Microsoft.Data.Sqlite; + +public class SqliteAlertStore : IAlertStore +{ + private readonly SqliteConnection _connection; + + public SqliteAlertStore(SqliteConnection connection) + { + _connection = connection; + } + + public async Task Add(DejaVuAlert alert) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO deja_vu_alerts (id, thread_id, matched_dead_end_id, confidence, + message, strategy, dismissed, created_at) + VALUES (@id, @threadId, @deadEndId, @confidence, + @message, @strategy, @dismissed, @createdAt) + """; + + cmd.Parameters.AddWithValue("@id", alert.Id); + cmd.Parameters.AddWithValue("@threadId", alert.ThreadId); + cmd.Parameters.AddWithValue("@deadEndId", alert.MatchedDeadEndId); + cmd.Parameters.AddWithValue("@confidence", alert.Confidence); + cmd.Parameters.AddWithValue("@message", alert.Message); + cmd.Parameters.AddWithValue("@strategy", alert.Strategy.ToString()); + cmd.Parameters.AddWithValue("@dismissed", alert.Dismissed ? 1 : 0); + cmd.Parameters.AddWithValue("@createdAt", alert.CreatedAt.ToString("o")); + + await cmd.ExecuteNonQueryAsync(); + return alert; + } + + public async Task> GetActive() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM deja_vu_alerts WHERE dismissed = 0 ORDER BY created_at DESC"; + return await ReadAlerts(cmd); + } + + public async Task> GetAll(int limit = 100) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM deja_vu_alerts ORDER BY created_at DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", limit); + return await ReadAlerts(cmd); + } + + public async Task Dismiss(string id) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "UPDATE deja_vu_alerts SET dismissed = 1 WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task Exists(string threadId, string deadEndId) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + SELECT COUNT(*) FROM deja_vu_alerts + WHERE thread_id = @threadId AND matched_dead_end_id = @deadEndId AND dismissed = 0 + """; + cmd.Parameters.AddWithValue("@threadId", threadId); + cmd.Parameters.AddWithValue("@deadEndId", deadEndId); + + var result = await cmd.ExecuteScalarAsync(); + return Convert.ToInt64(result) > 0; + } + + private static async Task> ReadAlerts(SqliteCommand cmd) + { + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new DejaVuAlert + { + Id = reader.GetString(reader.GetOrdinal("id")), + ThreadId = reader.GetString(reader.GetOrdinal("thread_id")), + MatchedDeadEndId = reader.GetString(reader.GetOrdinal("matched_dead_end_id")), + Confidence = reader.GetDouble(reader.GetOrdinal("confidence")), + Message = reader.GetString(reader.GetOrdinal("message")), + Strategy = Enum.Parse(reader.GetString(reader.GetOrdinal("strategy"))), + Dismissed = reader.GetInt32(reader.GetOrdinal("dismissed")) == 1, + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }); + } + return results; + } +} diff --git a/tests/DevBrain.Storage.Tests/SqliteAlertStoreTests.cs b/tests/DevBrain.Storage.Tests/SqliteAlertStoreTests.cs new file mode 100644 index 0000000..d8f8f81 --- /dev/null +++ b/tests/DevBrain.Storage.Tests/SqliteAlertStoreTests.cs @@ -0,0 +1,120 @@ +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class SqliteAlertStoreTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteAlertStore _store = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _store = new SqliteAlertStore(_connection); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task Add_And_GetActive_RoundTrips() + { + var alert = new DejaVuAlert + { + Id = "alert-1", + ThreadId = "t1", + MatchedDeadEndId = "de-1", + Confidence = 0.75, + Message = "You tried this before!", + Strategy = MatchStrategy.FileOverlap + }; + + await _store.Add(alert); + + var active = await _store.GetActive(); + Assert.Single(active); + Assert.Equal("alert-1", active[0].Id); + Assert.Equal(0.75, active[0].Confidence); + Assert.Equal(MatchStrategy.FileOverlap, active[0].Strategy); + Assert.False(active[0].Dismissed); + } + + [Fact] + public async Task Dismiss_RemovesFromActive() + { + var alert = new DejaVuAlert + { + Id = "alert-1", + ThreadId = "t1", + MatchedDeadEndId = "de-1", + Confidence = 0.8, + Message = "Warning", + Strategy = MatchStrategy.FileOverlap + }; + + await _store.Add(alert); + await _store.Dismiss("alert-1"); + + var active = await _store.GetActive(); + Assert.Empty(active); + + var all = await _store.GetAll(); + Assert.Single(all); + Assert.True(all[0].Dismissed); + } + + [Fact] + public async Task Exists_DetectsDuplicates() + { + await _store.Add(new DejaVuAlert + { + Id = "alert-1", ThreadId = "t1", MatchedDeadEndId = "de-1", + Confidence = 0.6, Message = "Dup check", Strategy = MatchStrategy.FileOverlap + }); + + Assert.True(await _store.Exists("t1", "de-1")); + Assert.False(await _store.Exists("t1", "de-999")); + Assert.False(await _store.Exists("t-other", "de-1")); + } + + [Fact] + public async Task Exists_IgnoresDismissedAlerts() + { + await _store.Add(new DejaVuAlert + { + Id = "alert-1", ThreadId = "t1", MatchedDeadEndId = "de-1", + Confidence = 0.7, Message = "Will dismiss", Strategy = MatchStrategy.FileOverlap + }); + + await _store.Dismiss("alert-1"); + Assert.False(await _store.Exists("t1", "de-1")); + } + + [Fact] + public async Task GetAll_ReturnsAllIncludingDismissed() + { + await _store.Add(new DejaVuAlert + { + Id = "a1", ThreadId = "t1", MatchedDeadEndId = "de-1", + Confidence = 0.5, Message = "First", Strategy = MatchStrategy.FileOverlap + }); + await _store.Add(new DejaVuAlert + { + Id = "a2", ThreadId = "t2", MatchedDeadEndId = "de-2", + Confidence = 0.9, Message = "Second", Strategy = MatchStrategy.Semantic + }); + await _store.Dismiss("a1"); + + var all = await _store.GetAll(); + Assert.Equal(2, all.Count); + } +} From 0f8dd41d51c479285fc4b3db3fd86f4dbead8054 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:34:45 -0700 Subject: [PATCH 08/36] feat(agents): implement DejaVuAgent with file overlap matching - Triggers on FileChange and Error events - Groups observations by thread, matches against dead-end store - Confidence threshold 0.5 (file overlap ratio) - Deduplicates via IAlertStore.Exists - Optional IAlertSink for SSE broadcast - 4 tests covering match, threshold, dedup, and threadId guard Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/DejaVuAgent.cs | 92 ++++++++++ .../DevBrain.Agents.Tests/DejaVuAgentTests.cs | 168 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/DevBrain.Agents/DejaVuAgent.cs create mode 100644 tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs diff --git a/src/DevBrain.Agents/DejaVuAgent.cs b/src/DevBrain.Agents/DejaVuAgent.cs new file mode 100644 index 0000000..2dfc2c4 --- /dev/null +++ b/src/DevBrain.Agents/DejaVuAgent.cs @@ -0,0 +1,92 @@ +namespace DevBrain.Agents; + +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class DejaVuAgent : IIntelligenceAgent +{ + private readonly IAlertStore _alertStore; + private readonly IAlertSink? _alertSink; + + public DejaVuAgent(IAlertStore alertStore, IAlertSink? alertSink = null) + { + _alertStore = alertStore; + _alertSink = alertSink; + } + + public string Name => "deja-vu"; + + public AgentSchedule Schedule => new AgentSchedule.OnEvent(EventType.FileChange, EventType.Error); + + public Priority Priority => Priority.Critical; + + public async Task> Run(AgentContext ctx, CancellationToken ct) + { + var outputs = new List(); + + var recent = await ctx.Observations.Query(new ObservationFilter + { + After = DateTime.UtcNow.AddMinutes(-10), + Limit = 50 + }); + + var threadGroups = recent + .Where(o => !string.IsNullOrEmpty(o.ThreadId)) + .GroupBy(o => o.ThreadId!); + + foreach (var group in threadGroups) + { + if (ct.IsCancellationRequested) break; + + var threadId = group.Key; + var currentFiles = group + .SelectMany(o => o.FilesInvolved) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (currentFiles.Count == 0) continue; + + var matchingDeadEnds = await ctx.DeadEnds.FindByFiles(currentFiles); + var currentFileSet = currentFiles.ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var deadEnd in matchingDeadEnds) + { + if (ct.IsCancellationRequested) break; + + var overlap = deadEnd.FilesInvolved.Count(f => currentFileSet.Contains(f)); + var confidence = (double)overlap / deadEnd.FilesInvolved.Count; + + if (confidence < 0.5) continue; + + if (await _alertStore.Exists(threadId, deadEnd.Id)) continue; + + var message = $"You may be heading toward a known dead end: {deadEnd.Description}. " + + $"Approach tried before: {deadEnd.Approach}. " + + $"Why it failed: {deadEnd.Reason}"; + + var alert = new DejaVuAlert + { + Id = Guid.NewGuid().ToString(), + ThreadId = threadId, + MatchedDeadEndId = deadEnd.Id, + Confidence = Math.Round(confidence, 2), + Message = message, + Strategy = MatchStrategy.FileOverlap + }; + + await _alertStore.Add(alert); + + if (_alertSink is not null) + await _alertSink.Send(alert, ct); + + outputs.Add(new AgentOutput( + AgentOutputType.AlertFired, + message, + alert)); + } + } + + return outputs; + } +} diff --git a/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs b/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs new file mode 100644 index 0000000..89c5c5f --- /dev/null +++ b/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs @@ -0,0 +1,168 @@ +using DevBrain.Agents; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Agents.Tests; + +public class DejaVuAgentTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private SqliteDeadEndStore _deadEndStore = null!; + private SqliteAlertStore _alertStore = null!; + private DejaVuAgent _agent = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _deadEndStore = new SqliteDeadEndStore(_connection); + _alertStore = new SqliteAlertStore(_connection); + _agent = new DejaVuAgent(_alertStore); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + private AgentContext CreateContext() + { + return new AgentContext( + Observations: _obsStore, + Graph: _graphStore, + Vectors: new NullVectorStore(), + Llm: new NullLlmService(), + Settings: new Settings(), + DeadEnds: _deadEndStore + ); + } + + [Fact] + public async Task Run_FiresAlertOnFileOverlapMatch() + { + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "FTS doesn't support CJK", + Approach = "Used default tokenizer", Reason = "No CJK support", + FilesInvolved = ["src/Search.cs", "src/Index.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "Editing search", FilesInvolved = ["src/Search.cs"] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Single(results); + Assert.Equal(AgentOutputType.AlertFired, results[0].Type); + + var active = await _alertStore.GetActive(); + Assert.Single(active); + Assert.Equal("de-1", active[0].MatchedDeadEndId); + Assert.Equal(MatchStrategy.FileOverlap, active[0].Strategy); + Assert.True(active[0].Confidence >= 0.5); + } + + [Fact] + public async Task Run_DoesNotFireWhenOverlapBelowThreshold() + { + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Complex issue", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs", "src/B.cs", "src/C.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "Editing A", FilesInvolved = ["src/A.cs"] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + Assert.Empty(await _alertStore.GetActive()); + } + + [Fact] + public async Task Run_DeduplicatesAlerts() + { + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Known issue", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow.AddMinutes(-5), Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "First edit", FilesInvolved = ["src/A.cs"] + }); + + var ctx = CreateContext(); + await _agent.Run(ctx, CancellationToken.None); + + await _obsStore.Add(new Observation + { + Id = "obs-2", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "Second edit", FilesInvolved = ["src/A.cs"] + }); + + var results2 = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results2); + Assert.Single(await _alertStore.GetActive()); + } + + [Fact] + public async Task Run_SkipsObservationsWithoutThreadId() + { + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Issue", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = null, + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "No thread", FilesInvolved = ["src/A.cs"] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + } +} From 59aee589b16a6caa7853d0d928be9c2b65ef78b2 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:36:17 -0700 Subject: [PATCH 09/36] feat(api): add AlertChannel, alert endpoints, wire DejaVuAgent - AlertChannel implements IAlertSink via bounded Channel - REST endpoints: GET /alerts, GET /alerts/all, POST /alerts/{id}/dismiss - SSE endpoint: GET /alerts/stream for real-time push - Wire IAlertStore, AlertChannel, DejaVuAgent in Program.cs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Api/Endpoints/AlertEndpoints.cs | 53 ++++++++++++++++++++ src/DevBrain.Api/Program.cs | 9 +++- src/DevBrain.Api/Services/AlertChannel.cs | 20 ++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/DevBrain.Api/Endpoints/AlertEndpoints.cs create mode 100644 src/DevBrain.Api/Services/AlertChannel.cs diff --git a/src/DevBrain.Api/Endpoints/AlertEndpoints.cs b/src/DevBrain.Api/Endpoints/AlertEndpoints.cs new file mode 100644 index 0000000..85ab9e4 --- /dev/null +++ b/src/DevBrain.Api/Endpoints/AlertEndpoints.cs @@ -0,0 +1,53 @@ +namespace DevBrain.Api.Endpoints; + +using DevBrain.Api.Services; +using DevBrain.Core.Interfaces; + +public static class AlertEndpoints +{ + public static void MapAlertEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/alerts"); + + group.MapGet("/", async (IAlertStore alertStore) => + { + var alerts = await alertStore.GetActive(); + return Results.Ok(alerts); + }); + + group.MapGet("/all", async (IAlertStore alertStore, int? limit) => + { + var alerts = await alertStore.GetAll(limit ?? 100); + return Results.Ok(alerts); + }); + + group.MapPost("/{id}/dismiss", async (string id, IAlertStore alertStore) => + { + await alertStore.Dismiss(id); + return Results.Ok(new { dismissed = true }); + }); + + group.MapGet("/stream", (AlertChannel channel, CancellationToken ct) => + { + return Results.Stream( + stream => WriteSSE(stream, channel, ct), + contentType: "text/event-stream"); + }); + } + + private static async Task WriteSSE(Stream stream, AlertChannel channel, CancellationToken ct) + { + var writer = new StreamWriter(stream) { AutoFlush = true }; + + await foreach (var alert in channel.ReadAllAsync(ct)) + { + if (ct.IsCancellationRequested) break; + + var json = System.Text.Json.JsonSerializer.Serialize(alert); + await writer.WriteLineAsync($"id: {alert.Id}"); + await writer.WriteLineAsync($"event: alert"); + await writer.WriteLineAsync($"data: {json}"); + await writer.WriteLineAsync(); + } + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index abe9044..56587eb 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -38,6 +38,8 @@ var observationStore = new SqliteObservationStore(connection); var graphStore = new SqliteGraphStore(connection); var deadEndStore = new SqliteDeadEndStore(connection); +var alertStore = new SqliteAlertStore(connection); +var alertChannel = new DevBrain.Api.Services.AlertChannel(); // ── Vector store (placeholder) ─────────────────────────────────────────────── var vectorStore = new NullVectorStore(); @@ -83,7 +85,8 @@ new DeadEndAgent(), new BriefingAgent(), new CompressionAgent(), - new DecisionChainAgent() + new DecisionChainAgent(), + new DejaVuAgent(alertStore, alertChannel) }; // ── ASP.NET Core host ──────────────────────────────────────────────────────── @@ -104,6 +107,9 @@ builder.Services.AddSingleton(graphStore); builder.Services.AddSingleton(vectorStore); builder.Services.AddSingleton(deadEndStore); +builder.Services.AddSingleton(alertStore); +builder.Services.AddSingleton(alertChannel); +builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(llmService); builder.Services.AddSingleton(llmService); // concrete type for ResetDailyCounter builder.Services.AddSingleton(eventBus); @@ -144,6 +150,7 @@ app.MapAdminEndpoints(); app.MapThreadEndpoints(); app.MapDeadEndEndpoints(); +app.MapAlertEndpoints(); app.MapContextEndpoints(); app.MapDatabaseEndpoints(); app.MapSetupEndpoints(); diff --git a/src/DevBrain.Api/Services/AlertChannel.cs b/src/DevBrain.Api/Services/AlertChannel.cs new file mode 100644 index 0000000..4773b18 --- /dev/null +++ b/src/DevBrain.Api/Services/AlertChannel.cs @@ -0,0 +1,20 @@ +namespace DevBrain.Api.Services; + +using System.Threading.Channels; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class AlertChannel : IAlertSink +{ + private readonly Channel _channel = Channel.CreateBounded(100); + + public async Task Send(DejaVuAlert alert, CancellationToken ct = default) + { + await _channel.Writer.WriteAsync(alert, ct); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken ct) + { + return _channel.Reader.ReadAllAsync(ct); + } +} From 1aa1b24a4a2dcec5535d48c8461dfabd593419a8 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:37:48 -0700 Subject: [PATCH 10/36] feat(cli): add alerts command with dismiss and history subcommands Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Cli/Commands/AlertsCommand.cs | 107 +++++++++++++++++++++ src/DevBrain.Cli/Program.cs | 1 + 2 files changed, 108 insertions(+) create mode 100644 src/DevBrain.Cli/Commands/AlertsCommand.cs diff --git a/src/DevBrain.Cli/Commands/AlertsCommand.cs b/src/DevBrain.Cli/Commands/AlertsCommand.cs new file mode 100644 index 0000000..6a66e70 --- /dev/null +++ b/src/DevBrain.Cli/Commands/AlertsCommand.cs @@ -0,0 +1,107 @@ +using System.CommandLine; +using System.Text.Json; +using DevBrain.Cli.Output; + +namespace DevBrain.Cli.Commands; + +public class AlertsCommand : Command +{ + public AlertsCommand() : base("alerts", "Show and manage deja vu alerts") + { + var dismissCmd = new Command("dismiss", "Dismiss an alert"); + var idArg = new Argument("id") { Description = "Alert ID to dismiss" }; + dismissCmd.Add(idArg); + dismissCmd.SetAction(async (pr) => + { + var id = pr.GetValue(idArg); + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + var response = await client.Post($"/api/v1/alerts/{id}/dismiss"); + if (response.IsSuccessStatusCode) + ConsoleFormatter.PrintSuccess($"Alert {id} dismissed."); + else + ConsoleFormatter.PrintError("Failed to dismiss alert."); + }); + + var historyCmd = new Command("history", "Show all alerts including dismissed"); + historyCmd.SetAction(async (pr) => + { + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + await PrintAlerts(client, "/api/v1/alerts/all", includeStatus: true); + }); + + Add(dismissCmd); + Add(historyCmd); + SetAction(Execute); + } + + private async Task Execute(ParseResult pr) + { + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + await PrintAlerts(client, "/api/v1/alerts", includeStatus: false); + } + + private static async Task PrintAlerts(DevBrainHttpClient client, string url, bool includeStatus) + { + try + { + var json = await client.GetJson(url); + + if (json.ValueKind != JsonValueKind.Array || json.GetArrayLength() == 0) + { + ConsoleFormatter.PrintSuccess("No active alerts."); + return; + } + + Console.WriteLine($"Found {json.GetArrayLength()} alert(s):\n"); + + foreach (var item in json.EnumerateArray()) + { + var message = item.GetPropertyOrDefault("message", "(no message)"); + var confidence = item.TryGetProperty("confidence", out var c) ? c.GetDouble() : 0; + var strategy = item.GetPropertyOrDefault("strategy", "unknown"); + var dismissed = item.TryGetProperty("dismissed", out var d) && d.GetBoolean(); + + if (dismissed) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(" - [DISMISSED] "); + } + else + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write(" ! "); + } + Console.ResetColor(); + + Console.WriteLine(message); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" Confidence: {confidence:P0} Strategy: {strategy}"); + + if (item.TryGetProperty("id", out var idProp)) + Console.WriteLine($" ID: {idProp.GetString()}"); + + Console.ResetColor(); + Console.WriteLine(); + } + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to fetch alerts: {ex.Message}"); + } + } +} diff --git a/src/DevBrain.Cli/Program.cs b/src/DevBrain.Cli/Program.cs index a86e4c9..b2c25a0 100644 --- a/src/DevBrain.Cli/Program.cs +++ b/src/DevBrain.Cli/Program.cs @@ -12,6 +12,7 @@ root.Add(new DashboardCommand()); root.Add(new ThreadCommand()); root.Add(new DeadEndsCommand()); +root.Add(new AlertsCommand()); root.Add(new RelatedCommand()); root.Add(new AgentsCommand()); root.Add(new ConfigCommand()); From 4bf0129532b025961b06316178ddd0645df1acea Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:40:24 -0700 Subject: [PATCH 11/36] feat(dashboard): add Alerts page, AlertBanner, and navigation - DejaVuAlert type and API methods in client.ts - Alerts page with dismiss buttons and show-dismissed toggle - AlertBanner component polls every 10s, shows at top of all pages - Route and nav link for /alerts Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/App.tsx | 4 + dashboard/src/api/client.ts | 24 +++ dashboard/src/components/AlertBanner.tsx | 61 ++++++++ dashboard/src/components/Navigation.tsx | 1 + dashboard/src/pages/Alerts.tsx | 180 +++++++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 dashboard/src/components/AlertBanner.tsx create mode 100644 dashboard/src/pages/Alerts.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 4291247..56c08cd 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import Navigation from './components/Navigation'; +import AlertBanner from './components/AlertBanner'; import Timeline from './pages/Timeline'; import Briefings from './pages/Briefings'; import DeadEnds from './pages/DeadEnds'; @@ -9,11 +10,13 @@ import SettingsPage from './pages/SettingsPage'; import Health from './pages/Health'; import Database from './pages/Database'; import Setup from './pages/Setup'; +import Alerts from './pages/Alerts'; export default function App() { return ( +
} /> @@ -25,6 +28,7 @@ export default function App() { } /> } /> } /> + } />
diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index b16d5ca..3241726 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -185,6 +185,18 @@ export interface DeadEnd { createdAt: string; } +// DejaVuAlert model +export interface DejaVuAlert { + id: string; + threadId: string; + matchedDeadEndId: string; + confidence: number; + message: string; + strategy: string; + dismissed: boolean; + createdAt: string; +} + // Database explorer types export interface DbTableInfo { name: string; @@ -331,6 +343,18 @@ export const api = { return fetchJson(`/dead-ends${qs ? `?${qs}` : ''}`); }, + // Alerts + alerts: () => fetchJson('/alerts'), + + alertsAll: () => fetchJson('/alerts/all'), + + alertDismiss: async (id: string) => { + const res = await fetch(`${BASE_URL}/alerts/${encodeURIComponent(id)}/dismiss`, { + method: 'POST', + }); + if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`); + }, + // Context fileContext: (path: string) => fetchJson(`/context/file/${encodeURIComponent(path)}`), diff --git a/dashboard/src/components/AlertBanner.tsx b/dashboard/src/components/AlertBanner.tsx new file mode 100644 index 0000000..befb991 --- /dev/null +++ b/dashboard/src/components/AlertBanner.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import { useNavigate } from 'react-router-dom'; + +export default function AlertBanner() { + const [count, setCount] = useState(0); + const [message, setMessage] = useState(''); + const navigate = useNavigate(); + + useEffect(() => { + const check = () => { + api.alerts().then((alerts) => { + setCount(alerts.length); + if (alerts.length > 0) setMessage(alerts[0].message); + }).catch(() => {}); + }; + + check(); + const interval = setInterval(check, 10000); + return () => clearInterval(interval); + }, []); + + if (count === 0) return null; + + return ( +
navigate('/alerts')}> + ! + + {count} active alert{count !== 1 ? 's' : ''} + {message && ` — ${message.slice(0, 80)}${message.length > 80 ? '...' : ''}`} + +
+ ); +} + +const styles: Record = { + banner: { + background: '#7c2d12', + color: '#fbbf24', + padding: '0.5rem 1.5rem', + fontSize: '0.85rem', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + }, + icon: { + background: '#fbbf24', + color: '#7c2d12', + borderRadius: '50%', + width: '1.2rem', + height: '1.2rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 800, + fontSize: '0.75rem', + flexShrink: 0, + }, + text: { lineHeight: 1.3 }, +}; diff --git a/dashboard/src/components/Navigation.tsx b/dashboard/src/components/Navigation.tsx index e13dff1..0e17a9b 100644 --- a/dashboard/src/components/Navigation.tsx +++ b/dashboard/src/components/Navigation.tsx @@ -4,6 +4,7 @@ const links = [ { to: '/', label: 'Timeline' }, { to: '/briefings', label: 'Briefings' }, { to: '/dead-ends', label: 'Dead Ends' }, + { to: '/alerts', label: 'Alerts' }, { to: '/threads', label: 'Threads' }, { to: '/search', label: 'Search' }, { to: '/settings', label: 'Settings' }, diff --git a/dashboard/src/pages/Alerts.tsx b/dashboard/src/pages/Alerts.tsx new file mode 100644 index 0000000..6312d60 --- /dev/null +++ b/dashboard/src/pages/Alerts.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api, type DejaVuAlert } from '../api/client'; + +export default function Alerts() { + const [alerts, setAlerts] = useState([]); + const [showDismissed, setShowDismissed] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadAlerts = useCallback(() => { + setLoading(true); + const fetcher = showDismissed ? api.alertsAll : api.alerts; + fetcher() + .then((data) => { + setAlerts(data); + setLoading(false); + }) + .catch((e) => { + setError(String(e)); + setLoading(false); + }); + }, [showDismissed]); + + useEffect(() => { + loadAlerts(); + }, [loadAlerts]); + + const handleDismiss = async (id: string) => { + try { + await api.alertDismiss(id); + loadAlerts(); + } catch (e) { + setError(String(e)); + } + }; + + if (error) return
Error: {error}
; + if (loading) return
Loading alerts...
; + + const activeCount = alerts.filter((a) => !a.dismissed).length; + + return ( +
+

Deja Vu Alerts

+ +
+ + {activeCount} active alert{activeCount !== 1 ? 's' : ''} + + +
+ + {alerts.length === 0 && ( +

+ {showDismissed + ? 'No alerts recorded yet.' + : "No active alerts. You're in the clear!"} +

+ )} + +
+ {alerts.map((alert) => ( +
+
+
+ + {alert.dismissed ? 'Dismissed' : 'Active'} + + {alert.strategy} + + {Math.round(alert.confidence * 100)}% match + +
+ + {new Date(alert.createdAt).toLocaleString()} + +
+ +
{alert.message}
+ + {!alert.dismissed && ( + + )} +
+ ))} +
+
+ ); +} + +const styles: Record = { + container: { padding: '1.5rem', maxWidth: 800, margin: '0 auto' }, + controls: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '1.5rem', + }, + count: { fontSize: '0.9rem', color: '#fbbf24', fontWeight: 600 }, + toggle: { fontSize: '0.85rem', color: '#9ca3af', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }, + list: { display: 'flex', flexDirection: 'column', gap: '0.75rem' }, + card: { + background: '#1f2028', + borderRadius: 8, + padding: '1rem', + border: '1px solid #7c2d12', + }, + cardDismissed: { + opacity: 0.5, + borderColor: '#2e303a', + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '0.75rem', + flexWrap: 'wrap' as const, + gap: '0.5rem', + }, + badges: { display: 'flex', gap: '0.5rem', alignItems: 'center' }, + badge: { + fontSize: '0.7rem', + padding: '2px 8px', + borderRadius: 4, + fontWeight: 600, + textTransform: 'uppercase' as const, + }, + strategyBadge: { + fontSize: '0.7rem', + background: '#1e293b', + color: '#60a5fa', + padding: '2px 8px', + borderRadius: 4, + fontFamily: 'monospace', + }, + confidence: { fontSize: '0.8rem', color: '#9ca3af' }, + date: { fontSize: '0.8rem', color: '#6b7280' }, + message: { + color: '#f3f4f6', + fontSize: '0.9rem', + lineHeight: 1.5, + }, + dismissBtn: { + marginTop: '0.75rem', + padding: '0.35rem 1rem', + background: '#374151', + color: '#d1d5db', + border: '1px solid #4b5563', + borderRadius: 4, + cursor: 'pointer', + fontSize: '0.8rem', + }, + loading: { padding: '2rem', textAlign: 'center' as const, color: '#9ca3af' }, + error: { padding: '2rem', textAlign: 'center' as const, color: '#ef4444' }, + empty: { color: '#6b7280', textAlign: 'center' as const, padding: '2rem' }, +}; From dd13a040b8904bc84f0db7dad28365d9e3c68b91 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 07:17:04 -0700 Subject: [PATCH 12/36] =?UTF-8?q?fix:=20address=20D=C3=A9j=C3=A0=20Vu=20co?= =?UTF-8?q?de=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - Guard against division-by-zero when deadEnd.FilesInvolved is empty High: - AlertChannel uses DropOldest to prevent blocking agents - Document single-consumer SSE limitation Medium: - Dismiss endpoint returns 404 on nonexistent alert IDs - IAlertStore.Dismiss returns bool for row-found check - CLI URL-encodes alert ID with Uri.EscapeDataString - StreamWriter in SSE uses await using for proper disposal - Limit cap (max 1000) on /alerts/all endpoint Low: - Remove unused includeStatus parameter from CLI PrintAlerts - Add IAlertSink integration test (CapturingAlertSink) - Add test for empty FilesInvolved guard All 66 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/DejaVuAgent.cs | 1 + src/DevBrain.Api/Endpoints/AlertEndpoints.cs | 11 ++-- src/DevBrain.Api/Services/AlertChannel.cs | 11 +++- src/DevBrain.Cli/Commands/AlertsCommand.cs | 12 ++-- src/DevBrain.Core/Interfaces/IAlertStore.cs | 2 +- src/DevBrain.Storage/SqliteAlertStore.cs | 5 +- .../DevBrain.Agents.Tests/DejaVuAgentTests.cs | 65 +++++++++++++++++++ 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/DevBrain.Agents/DejaVuAgent.cs b/src/DevBrain.Agents/DejaVuAgent.cs index 2dfc2c4..8fd38b2 100644 --- a/src/DevBrain.Agents/DejaVuAgent.cs +++ b/src/DevBrain.Agents/DejaVuAgent.cs @@ -53,6 +53,7 @@ public async Task> Run(AgentContext ctx, Cancellation foreach (var deadEnd in matchingDeadEnds) { if (ct.IsCancellationRequested) break; + if (deadEnd.FilesInvolved.Count == 0) continue; var overlap = deadEnd.FilesInvolved.Count(f => currentFileSet.Contains(f)); var confidence = (double)overlap / deadEnd.FilesInvolved.Count; diff --git a/src/DevBrain.Api/Endpoints/AlertEndpoints.cs b/src/DevBrain.Api/Endpoints/AlertEndpoints.cs index 85ab9e4..238c4c8 100644 --- a/src/DevBrain.Api/Endpoints/AlertEndpoints.cs +++ b/src/DevBrain.Api/Endpoints/AlertEndpoints.cs @@ -17,14 +17,17 @@ public static void MapAlertEndpoints(this WebApplication app) group.MapGet("/all", async (IAlertStore alertStore, int? limit) => { - var alerts = await alertStore.GetAll(limit ?? 100); + var capped = Math.Min(limit ?? 100, 1000); + var alerts = await alertStore.GetAll(capped); return Results.Ok(alerts); }); group.MapPost("/{id}/dismiss", async (string id, IAlertStore alertStore) => { - await alertStore.Dismiss(id); - return Results.Ok(new { dismissed = true }); + var found = await alertStore.Dismiss(id); + return found + ? Results.Ok(new { dismissed = true }) + : Results.NotFound(new { error = $"Alert '{id}' not found" }); }); group.MapGet("/stream", (AlertChannel channel, CancellationToken ct) => @@ -37,7 +40,7 @@ public static void MapAlertEndpoints(this WebApplication app) private static async Task WriteSSE(Stream stream, AlertChannel channel, CancellationToken ct) { - var writer = new StreamWriter(stream) { AutoFlush = true }; + await using var writer = new StreamWriter(stream) { AutoFlush = true }; await foreach (var alert in channel.ReadAllAsync(ct)) { diff --git a/src/DevBrain.Api/Services/AlertChannel.cs b/src/DevBrain.Api/Services/AlertChannel.cs index 4773b18..460065a 100644 --- a/src/DevBrain.Api/Services/AlertChannel.cs +++ b/src/DevBrain.Api/Services/AlertChannel.cs @@ -4,9 +4,18 @@ namespace DevBrain.Api.Services; using DevBrain.Core.Interfaces; using DevBrain.Core.Models; +/// +/// In-memory broadcast channel for SSE alert delivery. +/// Note: ReadAllAsync is single-consumer — only one SSE client receives each alert. +/// The dashboard also polls GET /alerts as a fallback, so this is acceptable for v1. +/// public class AlertChannel : IAlertSink { - private readonly Channel _channel = Channel.CreateBounded(100); + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest + }); public async Task Send(DejaVuAlert alert, CancellationToken ct = default) { diff --git a/src/DevBrain.Cli/Commands/AlertsCommand.cs b/src/DevBrain.Cli/Commands/AlertsCommand.cs index 6a66e70..f9ee82d 100644 --- a/src/DevBrain.Cli/Commands/AlertsCommand.cs +++ b/src/DevBrain.Cli/Commands/AlertsCommand.cs @@ -13,18 +13,18 @@ public AlertsCommand() : base("alerts", "Show and manage deja vu alerts") dismissCmd.Add(idArg); dismissCmd.SetAction(async (pr) => { - var id = pr.GetValue(idArg); + var id = pr.GetValue(idArg)!; var client = new DevBrainHttpClient(); if (!await client.IsHealthy()) { ConsoleFormatter.PrintError("Daemon is not running."); return; } - var response = await client.Post($"/api/v1/alerts/{id}/dismiss"); + var response = await client.Post($"/api/v1/alerts/{Uri.EscapeDataString(id)}/dismiss"); if (response.IsSuccessStatusCode) ConsoleFormatter.PrintSuccess($"Alert {id} dismissed."); else - ConsoleFormatter.PrintError("Failed to dismiss alert."); + ConsoleFormatter.PrintError("Failed to dismiss alert. It may not exist."); }); var historyCmd = new Command("history", "Show all alerts including dismissed"); @@ -36,7 +36,7 @@ public AlertsCommand() : base("alerts", "Show and manage deja vu alerts") ConsoleFormatter.PrintError("Daemon is not running."); return; } - await PrintAlerts(client, "/api/v1/alerts/all", includeStatus: true); + await PrintAlerts(client, "/api/v1/alerts/all"); }); Add(dismissCmd); @@ -52,10 +52,10 @@ private async Task Execute(ParseResult pr) ConsoleFormatter.PrintError("Daemon is not running."); return; } - await PrintAlerts(client, "/api/v1/alerts", includeStatus: false); + await PrintAlerts(client, "/api/v1/alerts"); } - private static async Task PrintAlerts(DevBrainHttpClient client, string url, bool includeStatus) + private static async Task PrintAlerts(DevBrainHttpClient client, string url) { try { diff --git a/src/DevBrain.Core/Interfaces/IAlertStore.cs b/src/DevBrain.Core/Interfaces/IAlertStore.cs index 18fbf56..ddf175d 100644 --- a/src/DevBrain.Core/Interfaces/IAlertStore.cs +++ b/src/DevBrain.Core/Interfaces/IAlertStore.cs @@ -7,6 +7,6 @@ public interface IAlertStore Task Add(DejaVuAlert alert); Task> GetActive(); Task> GetAll(int limit = 100); - Task Dismiss(string id); + Task Dismiss(string id); Task Exists(string threadId, string deadEndId); } diff --git a/src/DevBrain.Storage/SqliteAlertStore.cs b/src/DevBrain.Storage/SqliteAlertStore.cs index 1cc2a5a..4e03ff0 100644 --- a/src/DevBrain.Storage/SqliteAlertStore.cs +++ b/src/DevBrain.Storage/SqliteAlertStore.cs @@ -53,12 +53,13 @@ public async Task> GetAll(int limit = 100) return await ReadAlerts(cmd); } - public async Task Dismiss(string id) + public async Task Dismiss(string id) { using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE deja_vu_alerts SET dismissed = 1 WHERE id = @id"; cmd.Parameters.AddWithValue("@id", id); - await cmd.ExecuteNonQueryAsync(); + var rows = await cmd.ExecuteNonQueryAsync(); + return rows > 0; } public async Task Exists(string threadId, string deadEndId) diff --git a/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs b/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs index 89c5c5f..ddf84b2 100644 --- a/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/DejaVuAgentTests.cs @@ -141,6 +141,60 @@ await _obsStore.Add(new Observation Assert.Single(await _alertStore.GetActive()); } + [Fact] + public async Task Run_PushesToAlertSinkWhenProvided() + { + var sink = new CapturingAlertSink(); + var agentWithSink = new DejaVuAgent(_alertStore, sink); + + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "Known issue", Approach = "approach", Reason = "reason", + FilesInvolved = ["src/A.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "Editing A", FilesInvolved = ["src/A.cs"] + }); + + var ctx = CreateContext(); + await agentWithSink.Run(ctx, CancellationToken.None); + + Assert.Single(sink.SentAlerts); + Assert.Equal("de-1", sink.SentAlerts[0].MatchedDeadEndId); + } + + [Fact] + public async Task Run_SkipsDeadEndsWithEmptyFiles() + { + await _deadEndStore.Add(new DeadEnd + { + Id = "de-empty", Project = "proj", + Description = "Empty files", Approach = "approach", Reason = "reason", + FilesInvolved = [], + DetectedAt = DateTime.UtcNow.AddDays(-1) + }); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "Editing", FilesInvolved = ["src/A.cs"] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + } + [Fact] public async Task Run_SkipsObservationsWithoutThreadId() { @@ -165,4 +219,15 @@ await _obsStore.Add(new Observation Assert.Empty(results); } + + private class CapturingAlertSink : IAlertSink + { + public List SentAlerts { get; } = []; + + public Task Send(DejaVuAlert alert, CancellationToken ct = default) + { + SentAlerts.Add(alert); + return Task.CompletedTask; + } + } } From 8bd0c5fd8660ca14a54a29d26406e8e2a7584a09 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 07:49:57 -0700 Subject: [PATCH 13/36] =?UTF-8?q?feat:=20add=20Session=20Storytelling=20?= =?UTF-8?q?=E2=80=94=20ISessionStore,=20SqliteSessionStore,=20StorytellerA?= =?UTF-8?q?gent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ISessionStore with Add, GetBySessionId, GetAll, GetLatest - SqliteSessionStore persists to session_summaries table - StorytellerAgent: phase detection, turning points, LLM synthesis - Phase classification: Exploration, Implementation, Debugging, Refactoring - 4 store tests + 5 agent tests (including phase/turning point unit tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/DevBrain.Agents.csproj | 4 + src/DevBrain.Agents/StorytellerAgent.cs | 200 ++++++++++++++++++ src/DevBrain.Core/Interfaces/ISessionStore.cs | 11 + src/DevBrain.Storage/SqliteSessionStore.cs | 96 +++++++++ .../StorytellerAgentTests.cs | 192 +++++++++++++++++ .../SqliteSessionStoreTests.cs | 108 ++++++++++ 6 files changed, 611 insertions(+) create mode 100644 src/DevBrain.Agents/StorytellerAgent.cs create mode 100644 src/DevBrain.Core/Interfaces/ISessionStore.cs create mode 100644 src/DevBrain.Storage/SqliteSessionStore.cs create mode 100644 tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs create mode 100644 tests/DevBrain.Storage.Tests/SqliteSessionStoreTests.cs diff --git a/src/DevBrain.Agents/DevBrain.Agents.csproj b/src/DevBrain.Agents/DevBrain.Agents.csproj index 8d3f35f..ce2edab 100644 --- a/src/DevBrain.Agents/DevBrain.Agents.csproj +++ b/src/DevBrain.Agents/DevBrain.Agents.csproj @@ -17,4 +17,8 @@ enable + + + + diff --git a/src/DevBrain.Agents/StorytellerAgent.cs b/src/DevBrain.Agents/StorytellerAgent.cs new file mode 100644 index 0000000..fc47309 --- /dev/null +++ b/src/DevBrain.Agents/StorytellerAgent.cs @@ -0,0 +1,200 @@ +namespace DevBrain.Agents; + +using DevBrain.Core; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class StorytellerAgent : IIntelligenceAgent +{ + private readonly ISessionStore _sessionStore; + + public StorytellerAgent(ISessionStore sessionStore) + { + _sessionStore = sessionStore; + } + + public string Name => "storyteller"; + + public AgentSchedule Schedule => new AgentSchedule.Idle(TimeSpan.FromMinutes(30)); + + public Priority Priority => Priority.Normal; + + public async Task> Run(AgentContext ctx, CancellationToken ct) + { + var outputs = new List(); + + // Find sessions with recent observations that don't yet have a story + var recent = await ctx.Observations.Query(new ObservationFilter + { + After = DateTime.UtcNow.AddHours(-4), + Limit = 200 + }); + + var sessionIds = recent + .Select(o => o.SessionId) + .Distinct() + .ToList(); + + foreach (var sessionId in sessionIds) + { + if (ct.IsCancellationRequested) break; + + // Skip if already generated + var existing = await _sessionStore.GetBySessionId(sessionId); + if (existing is not null) continue; + + var observations = await ctx.Observations.GetSessionObservations(sessionId); + if (observations.Count < 3) continue; + + // Compute metrics + var duration = observations[^1].Timestamp - observations[0].Timestamp; + var filesTouched = observations + .SelectMany(o => o.FilesInvolved) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + + // Phase detection + var phases = DetectPhases(observations); + + // Turning points + var turningPoints = DetectTurningPoints(observations); + + // Dead ends in this session + var sessionFiles = observations + .SelectMany(o => o.FilesInvolved) + .Distinct() + .ToList(); + var deadEnds = sessionFiles.Count > 0 + ? await ctx.DeadEnds.FindByFiles(sessionFiles) + : []; + + // Build LLM prompt + var eventLines = observations.Select(o => + $"[{o.EventType}] {o.Timestamp:HH:mm}: {o.Summary ?? o.RawContent[..Math.Min(o.RawContent.Length, 100)]}" + ); + + var prompt = Prompts.Fill(Prompts.StorytellerNarrative, + ("DURATION", duration.ToString(@"h\h\ mm\m")), + ("PHASES", string.Join(" -> ", phases)), + ("TURNING_POINTS", string.Join("; ", turningPoints)), + ("EVENTS", string.Join("\n", eventLines))); + + var task = new LlmTask + { + AgentName = Name, + Priority = Priority.Normal, + Type = LlmTaskType.Synthesis, + Prompt = prompt, + Preference = LlmPreference.PreferCloud + }; + + LlmResult result; + try + { + result = await ctx.Llm.Submit(task, ct); + } + catch (OperationCanceledException) { throw; } + catch + { + continue; + } + + if (!result.Success || string.IsNullOrEmpty(result.Content)) + continue; + + // Parse narrative and outcome (last line is outcome) + var lines = result.Content.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries); + var outcome = lines.Length > 1 ? lines[^1].Trim() : "Session completed."; + var narrative = lines.Length > 1 + ? string.Join("\n", lines[..^1]).Trim() + : result.Content.Trim(); + + var summary = new SessionSummary + { + Id = Guid.NewGuid().ToString(), + SessionId = sessionId, + Narrative = narrative, + Outcome = outcome, + Duration = duration, + ObservationCount = observations.Count, + FilesTouched = filesTouched, + DeadEndsHit = deadEnds.Count, + Phases = phases + }; + + await _sessionStore.Add(summary); + + outputs.Add(new AgentOutput( + AgentOutputType.StoryGenerated, + $"Story generated for session {sessionId}: {outcome}")); + } + + return outputs; + } + + internal static IReadOnlyList DetectPhases(IReadOnlyList observations) + { + if (observations.Count == 0) return []; + + var phases = new List(); + var windowSize = TimeSpan.FromMinutes(10); + var start = observations[0].Timestamp; + var end = observations[^1].Timestamp; + + for (var windowStart = start; windowStart < end; windowStart += windowSize) + { + var windowEnd = windowStart + windowSize; + var windowObs = observations + .Where(o => o.Timestamp >= windowStart && o.Timestamp < windowEnd) + .ToList(); + + if (windowObs.Count == 0) continue; + + var phase = ClassifyPhase(windowObs); + if (phases.Count == 0 || phases[^1] != phase) + phases.Add(phase); + } + + return phases; + } + + private static string ClassifyPhase(List windowObs) + { + var errorCount = windowObs.Count(o => o.EventType == EventType.Error); + var fileChangeCount = windowObs.Count(o => o.EventType == EventType.FileChange); + var conversationCount = windowObs.Count(o => o.EventType == EventType.Conversation); + var hasRefactorTag = windowObs.Any(o => o.Tags.Any(t => + t.Contains("refactor", StringComparison.OrdinalIgnoreCase))); + + if (errorCount > 0 && fileChangeCount > 0) return "Debugging"; + if (hasRefactorTag && fileChangeCount > 0 && errorCount == 0) return "Refactoring"; + if (fileChangeCount > conversationCount) return "Implementation"; + return "Exploration"; + } + + internal static IReadOnlyList DetectTurningPoints(IReadOnlyList observations) + { + var points = new List(); + + for (int i = 0; i < observations.Count; i++) + { + var obs = observations[i]; + + // Decision events are turning points + if (obs.EventType == EventType.Decision) + points.Add($"Decision: {obs.Summary ?? obs.RawContent[..Math.Min(obs.RawContent.Length, 60)]}"); + + // Error followed by 10+ min of no errors = resolution + if (obs.EventType == EventType.Error) + { + var nextError = observations.Skip(i + 1) + .FirstOrDefault(o => o.EventType == EventType.Error); + if (nextError is null || (nextError.Timestamp - obs.Timestamp).TotalMinutes >= 10) + points.Add($"Error resolved at {obs.Timestamp:HH:mm}"); + } + } + + return points; + } +} diff --git a/src/DevBrain.Core/Interfaces/ISessionStore.cs b/src/DevBrain.Core/Interfaces/ISessionStore.cs new file mode 100644 index 0000000..823304e --- /dev/null +++ b/src/DevBrain.Core/Interfaces/ISessionStore.cs @@ -0,0 +1,11 @@ +namespace DevBrain.Core.Interfaces; + +using DevBrain.Core.Models; + +public interface ISessionStore +{ + Task Add(SessionSummary summary); + Task GetBySessionId(string sessionId); + Task> GetAll(int limit = 50); + Task GetLatest(); +} diff --git a/src/DevBrain.Storage/SqliteSessionStore.cs b/src/DevBrain.Storage/SqliteSessionStore.cs new file mode 100644 index 0000000..0ce0edc --- /dev/null +++ b/src/DevBrain.Storage/SqliteSessionStore.cs @@ -0,0 +1,96 @@ +namespace DevBrain.Storage; + +using System.Globalization; +using System.Text.Json; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using Microsoft.Data.Sqlite; + +public class SqliteSessionStore : ISessionStore +{ + private readonly SqliteConnection _connection; + + public SqliteSessionStore(SqliteConnection connection) + { + _connection = connection; + } + + public async Task Add(SessionSummary summary) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO session_summaries (id, session_id, narrative, outcome, + duration_seconds, observation_count, files_touched, dead_ends_hit, + phases, created_at) + VALUES (@id, @sessionId, @narrative, @outcome, + @durationSeconds, @observationCount, @filesTouched, @deadEndsHit, + @phases, @createdAt) + """; + + cmd.Parameters.AddWithValue("@id", summary.Id); + cmd.Parameters.AddWithValue("@sessionId", summary.SessionId); + cmd.Parameters.AddWithValue("@narrative", summary.Narrative); + cmd.Parameters.AddWithValue("@outcome", summary.Outcome); + cmd.Parameters.AddWithValue("@durationSeconds", (int)summary.Duration.TotalSeconds); + cmd.Parameters.AddWithValue("@observationCount", summary.ObservationCount); + cmd.Parameters.AddWithValue("@filesTouched", summary.FilesTouched); + cmd.Parameters.AddWithValue("@deadEndsHit", summary.DeadEndsHit); + cmd.Parameters.AddWithValue("@phases", JsonSerializer.Serialize(summary.Phases)); + cmd.Parameters.AddWithValue("@createdAt", summary.CreatedAt.ToString("o")); + + await cmd.ExecuteNonQueryAsync(); + return summary; + } + + public async Task GetBySessionId(string sessionId) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM session_summaries WHERE session_id = @sessionId"; + cmd.Parameters.AddWithValue("@sessionId", sessionId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapSummary(reader); + return null; + } + + public async Task> GetAll(int limit = 50) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", limit); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapSummary(reader)); + return results; + } + + public async Task GetLatest() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT 1"; + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapSummary(reader); + return null; + } + + private static SessionSummary MapSummary(SqliteDataReader reader) => new() + { + Id = reader.GetString(reader.GetOrdinal("id")), + SessionId = reader.GetString(reader.GetOrdinal("session_id")), + Narrative = reader.GetString(reader.GetOrdinal("narrative")), + Outcome = reader.GetString(reader.GetOrdinal("outcome")), + Duration = TimeSpan.FromSeconds(reader.GetInt32(reader.GetOrdinal("duration_seconds"))), + ObservationCount = reader.GetInt32(reader.GetOrdinal("observation_count")), + FilesTouched = reader.GetInt32(reader.GetOrdinal("files_touched")), + DeadEndsHit = reader.GetInt32(reader.GetOrdinal("dead_ends_hit")), + Phases = JsonSerializer.Deserialize>( + reader.GetString(reader.GetOrdinal("phases"))) ?? [], + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; +} diff --git a/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs b/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs new file mode 100644 index 0000000..b3a380b --- /dev/null +++ b/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs @@ -0,0 +1,192 @@ +using DevBrain.Agents; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Agents.Tests; + +public class StorytellerAgentTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private SqliteDeadEndStore _deadEndStore = null!; + private SqliteSessionStore _sessionStore = null!; + private StorytellerAgent _agent = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _deadEndStore = new SqliteDeadEndStore(_connection); + _sessionStore = new SqliteSessionStore(_connection); + _agent = new StorytellerAgent(_sessionStore); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + private AgentContext CreateContext(ILlmService? llm = null) + { + return new AgentContext( + Observations: _obsStore, + Graph: _graphStore, + Vectors: new NullVectorStore(), + Llm: llm ?? new StoryLlmService(), + Settings: new Settings(), + DeadEnds: _deadEndStore + ); + } + + [Fact] + public async Task Run_GeneratesStoryForSessionWithEnoughObservations() + { + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + await _obsStore.Add(new Observation + { + Id = $"obs-{i}", SessionId = "session-1", ThreadId = "t1", + Timestamp = now.AddMinutes(-30 + i * 5), Project = "proj", + EventType = i < 3 ? EventType.FileChange : EventType.Decision, + Source = CaptureSource.ClaudeCode, + RawContent = $"Activity {i}", Summary = $"Step {i}", + FilesInvolved = [$"src/File{i}.cs"] + }); + } + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Single(results); + Assert.Equal(AgentOutputType.StoryGenerated, results[0].Type); + + var story = await _sessionStore.GetBySessionId("session-1"); + Assert.NotNull(story); + Assert.Equal(5, story.ObservationCount); + Assert.True(story.FilesTouched >= 5); + } + + [Fact] + public async Task Run_SkipsSessionWithTooFewObservations() + { + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "session-short", ThreadId = "t1", + Timestamp = DateTime.UtcNow, Project = "proj", + EventType = EventType.Conversation, Source = CaptureSource.ClaudeCode, + RawContent = "Just one observation" + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + Assert.Null(await _sessionStore.GetBySessionId("session-short")); + } + + [Fact] + public async Task Run_SkipsAlreadyGeneratedSession() + { + var now = DateTime.UtcNow; + for (int i = 0; i < 4; i++) + { + await _obsStore.Add(new Observation + { + Id = $"obs-{i}", SessionId = "session-done", ThreadId = "t1", + Timestamp = now.AddMinutes(-20 + i * 5), Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = $"Activity {i}" + }); + } + + // Pre-create a story + await _sessionStore.Add(new SessionSummary + { + Id = "ss-existing", SessionId = "session-done", + Narrative = "Already generated", Outcome = "Done", + Duration = TimeSpan.FromMinutes(15), ObservationCount = 4, + FilesTouched = 0, DeadEndsHit = 0, Phases = [] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + } + + [Fact] + public void DetectPhases_IdentifiesPhaseTransitions() + { + var now = DateTime.UtcNow; + var observations = new List + { + MakeObs("1", now, EventType.Conversation), + MakeObs("2", now.AddMinutes(5), EventType.Conversation), + MakeObs("3", now.AddMinutes(15), EventType.FileChange), + MakeObs("4", now.AddMinutes(20), EventType.FileChange), + MakeObs("5", now.AddMinutes(25), EventType.Error), + MakeObs("6", now.AddMinutes(30), EventType.FileChange), + }; + + var phases = StorytellerAgent.DetectPhases(observations); + + Assert.Contains("Exploration", phases); + Assert.True(phases.Count >= 2); + } + + [Fact] + public void DetectTurningPoints_FindsDecisionsAndResolutions() + { + var now = DateTime.UtcNow; + var observations = new List + { + MakeObs("1", now, EventType.Error), + MakeObs("2", now.AddMinutes(15), EventType.Decision, + summary: "Decided to use mutex"), + MakeObs("3", now.AddMinutes(20), EventType.FileChange), + }; + + var points = StorytellerAgent.DetectTurningPoints(observations); + + Assert.Contains(points, p => p.Contains("Decision")); + Assert.Contains(points, p => p.Contains("resolved")); + } + + private static Observation MakeObs(string id, DateTime timestamp, EventType type, + string? summary = null) => new() + { + Id = id, SessionId = "s1", ThreadId = "t1", + Timestamp = timestamp, Project = "proj", + EventType = type, Source = CaptureSource.ClaudeCode, + RawContent = $"Content for {id}", + Summary = summary + }; + + private class StoryLlmService : ILlmService + { + public bool IsLocalAvailable => true; + public bool IsCloudAvailable => true; + public int CloudRequestsToday => 0; + public int QueueDepth => 0; + public Task Submit(LlmTask task, CancellationToken ct = default) + => Task.FromResult(new LlmResult + { + TaskId = task.Id, + Success = true, + Content = "The developer started by exploring the codebase.\n" + + "They implemented several changes across multiple files.\n" + + "Session outcome: Successfully completed the task." + }); + public Task Embed(string text, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + } +} diff --git a/tests/DevBrain.Storage.Tests/SqliteSessionStoreTests.cs b/tests/DevBrain.Storage.Tests/SqliteSessionStoreTests.cs new file mode 100644 index 0000000..d20e02a --- /dev/null +++ b/tests/DevBrain.Storage.Tests/SqliteSessionStoreTests.cs @@ -0,0 +1,108 @@ +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class SqliteSessionStoreTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteSessionStore _store = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _store = new SqliteSessionStore(_connection); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task Add_And_GetBySessionId_RoundTrips() + { + var summary = new SessionSummary + { + Id = "ss-1", + SessionId = "session-abc", + Narrative = "The developer started by investigating a bug...", + Outcome = "Fixed the race condition in auth middleware", + Duration = TimeSpan.FromMinutes(47), + ObservationCount = 23, + FilesTouched = 5, + DeadEndsHit = 1, + Phases = ["Exploration", "Debugging", "Implementation"] + }; + + await _store.Add(summary); + + var fetched = await _store.GetBySessionId("session-abc"); + Assert.NotNull(fetched); + Assert.Equal("ss-1", fetched.Id); + Assert.Equal("session-abc", fetched.SessionId); + Assert.Equal(TimeSpan.FromMinutes(47), fetched.Duration); + Assert.Equal(23, fetched.ObservationCount); + Assert.Equal(3, fetched.Phases.Count); + Assert.Contains("Debugging", fetched.Phases); + } + + [Fact] + public async Task GetLatest_ReturnsNewest() + { + await _store.Add(new SessionSummary + { + Id = "ss-old", SessionId = "s-old", + Narrative = "Old session", Outcome = "Done", + Duration = TimeSpan.FromMinutes(10), ObservationCount = 5, + FilesTouched = 2, DeadEndsHit = 0, Phases = [], + CreatedAt = DateTime.UtcNow.AddHours(-2) + }); + await _store.Add(new SessionSummary + { + Id = "ss-new", SessionId = "s-new", + Narrative = "New session", Outcome = "Also done", + Duration = TimeSpan.FromMinutes(30), ObservationCount = 15, + FilesTouched = 8, DeadEndsHit = 2, Phases = ["Implementation"] + }); + + var latest = await _store.GetLatest(); + Assert.NotNull(latest); + Assert.Equal("ss-new", latest.Id); + } + + [Fact] + public async Task GetBySessionId_ReturnsNullWhenNotFound() + { + var result = await _store.GetBySessionId("nonexistent"); + Assert.Null(result); + } + + [Fact] + public async Task GetAll_ReturnsAllSorted() + { + await _store.Add(new SessionSummary + { + Id = "ss-1", SessionId = "s1", + Narrative = "First", Outcome = "Done", + Duration = TimeSpan.FromMinutes(10), ObservationCount = 5, + FilesTouched = 2, DeadEndsHit = 0, Phases = [], + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await _store.Add(new SessionSummary + { + Id = "ss-2", SessionId = "s2", + Narrative = "Second", Outcome = "Done", + Duration = TimeSpan.FromMinutes(20), ObservationCount = 10, + FilesTouched = 4, DeadEndsHit = 1, Phases = [] + }); + + var all = await _store.GetAll(); + Assert.Equal(2, all.Count); + Assert.Equal("ss-2", all[0].Id); // newest first + } +} From c84b18db33798c7f169517cf3530c419baff977b Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:00:40 -0700 Subject: [PATCH 14/36] =?UTF-8?q?feat:=20complete=20Session=20Storytelling?= =?UTF-8?q?=20=E2=80=94=20API,=20CLI,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionEndpoints: GET /sessions, GET /sessions/{id}, GET/POST /sessions/{id}/story - StoryCommand CLI: devbrain story [--session id] - Sessions dashboard page with phase bar, expandable narrative, copy-as-markdown - Wire ISessionStore, StorytellerAgent in Program.cs - API client types + methods for SessionSummary Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/App.tsx | 2 + dashboard/src/api/client.ts | 21 ++ dashboard/src/components/Navigation.tsx | 1 + dashboard/src/pages/Sessions.tsx | 205 ++++++++++++++++++ .../Endpoints/SessionEndpoints.cs | 58 +++++ src/DevBrain.Api/Program.cs | 6 +- src/DevBrain.Cli/Commands/StoryCommand.cs | 87 ++++++++ src/DevBrain.Cli/Program.cs | 1 + 8 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/pages/Sessions.tsx create mode 100644 src/DevBrain.Api/Endpoints/SessionEndpoints.cs create mode 100644 src/DevBrain.Cli/Commands/StoryCommand.cs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 56c08cd..5c0c6e2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -11,6 +11,7 @@ import Health from './pages/Health'; import Database from './pages/Database'; import Setup from './pages/Setup'; import Alerts from './pages/Alerts'; +import Sessions from './pages/Sessions'; export default function App() { return ( @@ -29,6 +30,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 3241726..16f6e5b 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -197,6 +197,20 @@ export interface DejaVuAlert { createdAt: string; } +// SessionSummary model +export interface SessionSummary { + id: string; + sessionId: string; + narrative: string; + outcome: string; + duration: string; + observationCount: number; + filesTouched: number; + deadEndsHit: number; + phases: string[]; + createdAt: string; +} + // Database explorer types export interface DbTableInfo { name: string; @@ -355,6 +369,13 @@ export const api = { if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`); }, + // Sessions + sessions: (limit = 50) => + fetchJson(`/sessions?limit=${limit}`), + + sessionStory: (id: string) => + fetchJson(`/sessions/${encodeURIComponent(id)}/story`), + // Context fileContext: (path: string) => fetchJson(`/context/file/${encodeURIComponent(path)}`), diff --git a/dashboard/src/components/Navigation.tsx b/dashboard/src/components/Navigation.tsx index 0e17a9b..8a45cc6 100644 --- a/dashboard/src/components/Navigation.tsx +++ b/dashboard/src/components/Navigation.tsx @@ -5,6 +5,7 @@ const links = [ { to: '/briefings', label: 'Briefings' }, { to: '/dead-ends', label: 'Dead Ends' }, { to: '/alerts', label: 'Alerts' }, + { to: '/sessions', label: 'Sessions' }, { to: '/threads', label: 'Threads' }, { to: '/search', label: 'Search' }, { to: '/settings', label: 'Settings' }, diff --git a/dashboard/src/pages/Sessions.tsx b/dashboard/src/pages/Sessions.tsx new file mode 100644 index 0000000..8d7e153 --- /dev/null +++ b/dashboard/src/pages/Sessions.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; +import { api, type SessionSummary } from '../api/client'; + +const phaseColors: Record = { + Exploration: '#3b82f6', + Implementation: '#22c55e', + Debugging: '#ef4444', + Refactoring: '#eab308', +}; + +export default function Sessions() { + const [sessions, setSessions] = useState([]); + const [expanded, setExpanded] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + api + .sessions() + .then((data) => { + setSessions(data); + setLoading(false); + }) + .catch((e) => { + setError(String(e)); + setLoading(false); + }); + }, []); + + const copyMarkdown = (session: SessionSummary) => { + const md = `## Session Story\n\n${session.narrative}\n\n**Outcome:** ${session.outcome}\n\n_${session.observationCount} observations | ${session.filesTouched} files | ${session.deadEndsHit} dead ends_`; + navigator.clipboard.writeText(md); + }; + + if (error) return
Error: {error}
; + if (loading) return
Loading sessions...
; + + return ( +
+

Sessions

+ + {sessions.length === 0 && ( +

No session stories generated yet.

+ )} + +
+ {sessions.map((session) => ( +
+
+
+ {session.observationCount} obs + {session.filesTouched} files + {session.deadEndsHit > 0 && ( + + {session.deadEndsHit} dead ends + + )} +
+ + {new Date(session.createdAt).toLocaleDateString()} + +
+ + {/* Phase bar */} + {session.phases.length > 0 && ( +
+ {session.phases.map((phase, i) => ( +
+ ))} +
+ )} +
+ {session.phases.map((phase, i) => ( + + {phase} + + ))} +
+ +
{session.outcome}
+ +
+ + +
+ + {expanded === session.id && ( +
{session.narrative}
+ )} +
+ ))} +
+
+ ); +} + +const styles: Record = { + container: { padding: '1.5rem', maxWidth: 800, margin: '0 auto' }, + list: { display: 'flex', flexDirection: 'column', gap: '0.75rem' }, + card: { + background: '#1f2028', + borderRadius: 8, + padding: '1rem', + border: '1px solid #2e303a', + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '0.75rem', + }, + stats: { display: 'flex', gap: '0.75rem' }, + stat: { + fontSize: '0.8rem', + color: '#9ca3af', + background: '#1e293b', + padding: '2px 8px', + borderRadius: 4, + fontFamily: 'monospace', + }, + date: { fontSize: '0.8rem', color: '#6b7280' }, + phaseBar: { + display: 'flex', + height: 6, + borderRadius: 3, + overflow: 'hidden', + gap: 2, + marginBottom: '0.35rem', + }, + phaseSegment: { borderRadius: 2 }, + phaseLabels: { + display: 'flex', + gap: '0.5rem', + marginBottom: '0.75rem', + }, + phaseLabel: { + fontSize: '0.7rem', + fontWeight: 600, + textTransform: 'uppercase' as const, + }, + outcome: { + color: '#d1d5db', + fontSize: '0.9rem', + marginBottom: '0.75rem', + lineHeight: 1.4, + }, + actions: { display: 'flex', gap: '0.5rem' }, + expandBtn: { + padding: '0.35rem 1rem', + background: '#2a2a4a', + color: '#a5b4fc', + border: '1px solid #3b3b6b', + borderRadius: 4, + cursor: 'pointer', + fontSize: '0.8rem', + }, + copyBtn: { + padding: '0.35rem 1rem', + background: '#374151', + color: '#d1d5db', + border: '1px solid #4b5563', + borderRadius: 4, + cursor: 'pointer', + fontSize: '0.8rem', + }, + narrative: { + marginTop: '1rem', + padding: '1rem', + background: '#161620', + borderRadius: 6, + color: '#e5e7eb', + fontSize: '0.9rem', + lineHeight: 1.6, + whiteSpace: 'pre-wrap' as const, + }, + loading: { padding: '2rem', textAlign: 'center' as const, color: '#9ca3af' }, + error: { padding: '2rem', textAlign: 'center' as const, color: '#ef4444' }, + empty: { color: '#6b7280', textAlign: 'center' as const, padding: '2rem' }, +}; diff --git a/src/DevBrain.Api/Endpoints/SessionEndpoints.cs b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs new file mode 100644 index 0000000..0eacf68 --- /dev/null +++ b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs @@ -0,0 +1,58 @@ +namespace DevBrain.Api.Endpoints; + +using DevBrain.Core.Interfaces; + +public static class SessionEndpoints +{ + public static void MapSessionEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/sessions"); + + // List all sessions with summaries + group.MapGet("/", async (ISessionStore sessionStore, int? limit) => + { + var capped = Math.Min(limit ?? 50, 200); + var sessions = await sessionStore.GetAll(capped); + return Results.Ok(sessions); + }); + + // Get session story by session ID + group.MapGet("/{id}/story", async (string id, ISessionStore sessionStore) => + { + var summary = await sessionStore.GetBySessionId(id); + return summary is not null + ? Results.Ok(summary) + : Results.NotFound(new { error = $"No story for session '{id}'" }); + }); + + // Get session detail with observations + group.MapGet("/{id}", async (string id, + IObservationStore obsStore, ISessionStore sessionStore) => + { + var observations = await obsStore.GetSessionObservations(id); + var story = await sessionStore.GetBySessionId(id); + + return Results.Ok(new + { + sessionId = id, + observations, + story + }); + }); + + // Trigger story generation on demand (fire-and-forget via agent) + group.MapPost("/{id}/story", async (string id, + IObservationStore obsStore, ISessionStore sessionStore) => + { + var existing = await sessionStore.GetBySessionId(id); + if (existing is not null) + return Results.Ok(new { status = "already_generated", story = existing }); + + var observations = await obsStore.GetSessionObservations(id); + if (observations.Count < 3) + return Results.BadRequest(new { error = "Session has fewer than 3 observations" }); + + return Results.Accepted(new { status = "queued", message = "Story generation will run on next agent cycle" }); + }); + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 56587eb..64bee56 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -39,6 +39,7 @@ var graphStore = new SqliteGraphStore(connection); var deadEndStore = new SqliteDeadEndStore(connection); var alertStore = new SqliteAlertStore(connection); +var sessionStore = new SqliteSessionStore(connection); var alertChannel = new DevBrain.Api.Services.AlertChannel(); // ── Vector store (placeholder) ─────────────────────────────────────────────── @@ -86,7 +87,8 @@ new BriefingAgent(), new CompressionAgent(), new DecisionChainAgent(), - new DejaVuAgent(alertStore, alertChannel) + new DejaVuAgent(alertStore, alertChannel), + new StorytellerAgent(sessionStore) }; // ── ASP.NET Core host ──────────────────────────────────────────────────────── @@ -110,6 +112,7 @@ builder.Services.AddSingleton(alertStore); builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(alertChannel); +builder.Services.AddSingleton(sessionStore); builder.Services.AddSingleton(llmService); builder.Services.AddSingleton(llmService); // concrete type for ResetDailyCounter builder.Services.AddSingleton(eventBus); @@ -151,6 +154,7 @@ app.MapThreadEndpoints(); app.MapDeadEndEndpoints(); app.MapAlertEndpoints(); +app.MapSessionEndpoints(); app.MapContextEndpoints(); app.MapDatabaseEndpoints(); app.MapSetupEndpoints(); diff --git a/src/DevBrain.Cli/Commands/StoryCommand.cs b/src/DevBrain.Cli/Commands/StoryCommand.cs new file mode 100644 index 0000000..e049852 --- /dev/null +++ b/src/DevBrain.Cli/Commands/StoryCommand.cs @@ -0,0 +1,87 @@ +using System.CommandLine; +using System.Text.Json; +using DevBrain.Cli.Output; + +namespace DevBrain.Cli.Commands; + +public class StoryCommand : Command +{ + private readonly Option _sessionOption = new("--session") + { + Description = "Session ID (defaults to latest)" + }; + + public StoryCommand() : base("story", "Show session story narrative") + { + Add(_sessionOption); + SetAction(Execute); + } + + private async Task Execute(ParseResult pr) + { + var sessionId = pr.GetValue(_sessionOption); + var client = new DevBrainHttpClient(); + + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + + try + { + JsonElement json; + + if (!string.IsNullOrEmpty(sessionId)) + { + json = await client.GetJson($"/api/v1/sessions/{Uri.EscapeDataString(sessionId)}/story"); + } + else + { + // Get latest + var sessionsJson = await client.GetJson("/api/v1/sessions?limit=1"); + if (sessionsJson.ValueKind != JsonValueKind.Array || sessionsJson.GetArrayLength() == 0) + { + ConsoleFormatter.PrintWarning("No session stories available yet."); + return; + } + json = sessionsJson[0]; + } + + var narrative = json.GetPropertyOrDefault("narrative", ""); + var outcome = json.GetPropertyOrDefault("outcome", ""); + var duration = json.TryGetProperty("duration", out var d) + ? d.ToString() + : json.GetPropertyOrDefault("durationSeconds", "?") + "s"; + var obsCount = json.TryGetProperty("observationCount", out var oc) ? oc.GetInt32() : 0; + var filesCount = json.TryGetProperty("filesTouched", out var fc) ? fc.GetInt32() : 0; + var deadEnds = json.TryGetProperty("deadEndsHit", out var de) ? de.GetInt32() : 0; + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($" Session Story"); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" {obsCount} observations | {filesCount} files | {deadEnds} dead ends"); + Console.ResetColor(); + Console.WriteLine(); + + Console.WriteLine(narrative); + Console.WriteLine(); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Write(" Outcome: "); + Console.ResetColor(); + Console.WriteLine(outcome); + Console.WriteLine(); + } + catch (HttpRequestException) + { + ConsoleFormatter.PrintWarning("No story available for this session."); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to fetch story: {ex.Message}"); + } + } +} diff --git a/src/DevBrain.Cli/Program.cs b/src/DevBrain.Cli/Program.cs index b2c25a0..4f595f2 100644 --- a/src/DevBrain.Cli/Program.cs +++ b/src/DevBrain.Cli/Program.cs @@ -13,6 +13,7 @@ root.Add(new ThreadCommand()); root.Add(new DeadEndsCommand()); root.Add(new AlertsCommand()); +root.Add(new StoryCommand()); root.Add(new RelatedCommand()); root.Add(new AgentsCommand()); root.Add(new ConfigCommand()); From c2f3c38e97d9e2c92cdc2885b9fa43506bd23424 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:32:14 -0700 Subject: [PATCH 15/36] fix: address Session Storytelling review findings Critical: - Remove dead durationSeconds fallback in CLI, display TimeSpan directly - Replace fragile RawContent slicing with safe Truncate() helper High: - POST /sessions/{id}/story now honest about being validation-only (v1) - Add ISessionStore.GetByDateRange for Growth Tracker needs - Rename route params from {id} to {sessionId} for clarity Medium: - DetectPhases handles sessions shorter than 10 min / same timestamp - "Error resolved" label changed to "Error at X, no recurrence after" with guard requiring subsequent non-error activity - Fix case sensitivity inconsistency in sessionFiles Distinct() - Add INSERT OR IGNORE for TOCTOU safety on session_summaries - Wrap dashboard phase labels in same conditional as phase bar Low: - CLI differentiates 404 vs other HTTP errors - Add 4 new tests: single window, same timestamp, empty observations, LLM failure path All 79 tests passing, dashboard builds clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/pages/Sessions.tsx | 56 +++++++------- src/DevBrain.Agents/StorytellerAgent.cs | 29 +++++-- .../Endpoints/SessionEndpoints.cs | 22 +++--- src/DevBrain.Cli/Commands/StoryCommand.cs | 12 +-- src/DevBrain.Core/Interfaces/ISessionStore.cs | 1 + src/DevBrain.Storage/SqliteSessionStore.cs | 16 +++- .../StorytellerAgentTests.cs | 76 ++++++++++++++++++- 7 files changed, 162 insertions(+), 50 deletions(-) diff --git a/dashboard/src/pages/Sessions.tsx b/dashboard/src/pages/Sessions.tsx index 8d7e153..05ac1c3 100644 --- a/dashboard/src/pages/Sessions.tsx +++ b/dashboard/src/pages/Sessions.tsx @@ -61,35 +61,37 @@ export default function Sessions() {
- {/* Phase bar */} + {/* Phase bar + labels */} {session.phases.length > 0 && ( -
- {session.phases.map((phase, i) => ( -
- ))} -
+ <> +
+ {session.phases.map((phase, i) => ( +
+ ))} +
+
+ {session.phases.map((phase, i) => ( + + {phase} + + ))} +
+ )} -
- {session.phases.map((phase, i) => ( - - {phase} - - ))} -
{session.outcome}
diff --git a/src/DevBrain.Agents/StorytellerAgent.cs b/src/DevBrain.Agents/StorytellerAgent.cs index fc47309..54ed00d 100644 --- a/src/DevBrain.Agents/StorytellerAgent.cs +++ b/src/DevBrain.Agents/StorytellerAgent.cs @@ -63,7 +63,7 @@ public async Task> Run(AgentContext ctx, Cancellation // Dead ends in this session var sessionFiles = observations .SelectMany(o => o.FilesInvolved) - .Distinct() + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var deadEnds = sessionFiles.Count > 0 ? await ctx.DeadEnds.FindByFiles(sessionFiles) @@ -71,7 +71,7 @@ public async Task> Run(AgentContext ctx, Cancellation // Build LLM prompt var eventLines = observations.Select(o => - $"[{o.EventType}] {o.Timestamp:HH:mm}: {o.Summary ?? o.RawContent[..Math.Min(o.RawContent.Length, 100)]}" + $"[{o.EventType}] {o.Timestamp:HH:mm}: {Truncate(o.Summary ?? o.RawContent, 100)}" ); var prompt = Prompts.Fill(Prompts.StorytellerNarrative, @@ -142,6 +142,13 @@ internal static IReadOnlyList DetectPhases(IReadOnlyList ob var start = observations[0].Timestamp; var end = observations[^1].Timestamp; + // Handle sessions shorter than one window (including same-timestamp) + if (end - start < windowSize) + { + phases.Add(ClassifyPhase(observations.ToList())); + return phases; + } + for (var windowStart = start; windowStart < end; windowStart += windowSize) { var windowEnd = windowStart + windowSize; @@ -183,18 +190,28 @@ internal static IReadOnlyList DetectTurningPoints(IReadOnlyList o.EventType == EventType.Error); - if (nextError is null || (nextError.Timestamp - obs.Timestamp).TotalMinutes >= 10) - points.Add($"Error resolved at {obs.Timestamp:HH:mm}"); + var hasSubsequentActivity = observations.Skip(i + 1) + .Any(o => o.EventType != EventType.Error); + + if (hasSubsequentActivity && + (nextError is null || (nextError.Timestamp - obs.Timestamp).TotalMinutes >= 10)) + points.Add($"Error at {obs.Timestamp:HH:mm}, no recurrence after"); } } return points; } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value)) return ""; + return value.Length <= maxLength ? value : value[..maxLength] + "..."; + } } diff --git a/src/DevBrain.Api/Endpoints/SessionEndpoints.cs b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs index 0eacf68..944f586 100644 --- a/src/DevBrain.Api/Endpoints/SessionEndpoints.cs +++ b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs @@ -17,7 +17,7 @@ public static void MapSessionEndpoints(this WebApplication app) }); // Get session story by session ID - group.MapGet("/{id}/story", async (string id, ISessionStore sessionStore) => + group.MapGet("/{sessionId}/story", async (string sessionId, ISessionStore sessionStore) => { var summary = await sessionStore.GetBySessionId(id); return summary is not null @@ -26,33 +26,35 @@ public static void MapSessionEndpoints(this WebApplication app) }); // Get session detail with observations - group.MapGet("/{id}", async (string id, + group.MapGet("/{sessionId}", async (string sessionId, IObservationStore obsStore, ISessionStore sessionStore) => { - var observations = await obsStore.GetSessionObservations(id); - var story = await sessionStore.GetBySessionId(id); + var observations = await obsStore.GetSessionObservations(sessionId); + var story = await sessionStore.GetBySessionId(sessionId); return Results.Ok(new { - sessionId = id, + sessionId, observations, story }); }); - // Trigger story generation on demand (fire-and-forget via agent) - group.MapPost("/{id}/story", async (string id, + // Validate whether a session can have a story generated. + // Actual generation runs via StorytellerAgent on idle schedule. + // Known limitation: no way to trigger immediate generation (v1). + group.MapPost("/{sessionId}/story", async (string sessionId, IObservationStore obsStore, ISessionStore sessionStore) => { - var existing = await sessionStore.GetBySessionId(id); + var existing = await sessionStore.GetBySessionId(sessionId); if (existing is not null) return Results.Ok(new { status = "already_generated", story = existing }); - var observations = await obsStore.GetSessionObservations(id); + var observations = await obsStore.GetSessionObservations(sessionId); if (observations.Count < 3) return Results.BadRequest(new { error = "Session has fewer than 3 observations" }); - return Results.Accepted(new { status = "queued", message = "Story generation will run on next agent cycle" }); + return Results.Ok(new { status = "pending", message = "Session is eligible. Story will be generated when the storyteller agent runs next." }); }); } } diff --git a/src/DevBrain.Cli/Commands/StoryCommand.cs b/src/DevBrain.Cli/Commands/StoryCommand.cs index e049852..793ea77 100644 --- a/src/DevBrain.Cli/Commands/StoryCommand.cs +++ b/src/DevBrain.Cli/Commands/StoryCommand.cs @@ -50,16 +50,14 @@ private async Task Execute(ParseResult pr) var narrative = json.GetPropertyOrDefault("narrative", ""); var outcome = json.GetPropertyOrDefault("outcome", ""); - var duration = json.TryGetProperty("duration", out var d) - ? d.ToString() - : json.GetPropertyOrDefault("durationSeconds", "?") + "s"; + var duration = json.TryGetProperty("duration", out var d) ? d.ToString() : "?"; var obsCount = json.TryGetProperty("observationCount", out var oc) ? oc.GetInt32() : 0; var filesCount = json.TryGetProperty("filesTouched", out var fc) ? fc.GetInt32() : 0; var deadEnds = json.TryGetProperty("deadEndsHit", out var de) ? de.GetInt32() : 0; Console.WriteLine(); Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($" Session Story"); + Console.WriteLine($" Session Story ({duration})"); Console.ResetColor(); Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine($" {obsCount} observations | {filesCount} files | {deadEnds} dead ends"); @@ -75,10 +73,14 @@ private async Task Execute(ParseResult pr) Console.WriteLine(outcome); Console.WriteLine(); } - catch (HttpRequestException) + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { ConsoleFormatter.PrintWarning("No story available for this session."); } + catch (HttpRequestException ex) + { + ConsoleFormatter.PrintError($"API error: {ex.StatusCode} - {ex.Message}"); + } catch (Exception ex) { ConsoleFormatter.PrintError($"Failed to fetch story: {ex.Message}"); diff --git a/src/DevBrain.Core/Interfaces/ISessionStore.cs b/src/DevBrain.Core/Interfaces/ISessionStore.cs index 823304e..7876aaa 100644 --- a/src/DevBrain.Core/Interfaces/ISessionStore.cs +++ b/src/DevBrain.Core/Interfaces/ISessionStore.cs @@ -8,4 +8,5 @@ public interface ISessionStore Task GetBySessionId(string sessionId); Task> GetAll(int limit = 50); Task GetLatest(); + Task> GetByDateRange(DateTime after, DateTime before); } diff --git a/src/DevBrain.Storage/SqliteSessionStore.cs b/src/DevBrain.Storage/SqliteSessionStore.cs index 0ce0edc..734a57b 100644 --- a/src/DevBrain.Storage/SqliteSessionStore.cs +++ b/src/DevBrain.Storage/SqliteSessionStore.cs @@ -19,7 +19,7 @@ public async Task Add(SessionSummary summary) { using var cmd = _connection.CreateCommand(); cmd.CommandText = """ - INSERT INTO session_summaries (id, session_id, narrative, outcome, + INSERT OR IGNORE INTO session_summaries (id, session_id, narrative, outcome, duration_seconds, observation_count, files_touched, dead_ends_hit, phases, created_at) VALUES (@id, @sessionId, @narrative, @outcome, @@ -78,6 +78,20 @@ public async Task> GetAll(int limit = 50) return null; } + public async Task> GetByDateRange(DateTime after, DateTime before) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM session_summaries WHERE created_at > @after AND created_at < @before ORDER BY created_at DESC"; + cmd.Parameters.AddWithValue("@after", after.ToString("o")); + cmd.Parameters.AddWithValue("@before", before.ToString("o")); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapSummary(reader)); + return results; + } + private static SessionSummary MapSummary(SqliteDataReader reader) => new() { Id = reader.GetString(reader.GetOrdinal("id")), diff --git a/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs b/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs index b3a380b..905b981 100644 --- a/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/StorytellerAgentTests.cs @@ -143,6 +143,68 @@ public void DetectPhases_IdentifiesPhaseTransitions() Assert.True(phases.Count >= 2); } + [Fact] + public void DetectPhases_SingleWindowSession() + { + var now = DateTime.UtcNow; + var observations = new List + { + MakeObs("1", now, EventType.FileChange), + MakeObs("2", now.AddMinutes(2), EventType.FileChange), + MakeObs("3", now.AddMinutes(5), EventType.Error), + }; + + var phases = StorytellerAgent.DetectPhases(observations); + + Assert.NotEmpty(phases); + } + + [Fact] + public void DetectPhases_SameTimestamp_ReturnsOnePhase() + { + var now = DateTime.UtcNow; + var observations = new List + { + MakeObs("1", now, EventType.Conversation), + MakeObs("2", now, EventType.Conversation), + MakeObs("3", now, EventType.Conversation), + }; + + var phases = StorytellerAgent.DetectPhases(observations); + + Assert.Single(phases); + Assert.Equal("Exploration", phases[0]); + } + + [Fact] + public void DetectPhases_EmptyObservations_ReturnsEmpty() + { + var phases = StorytellerAgent.DetectPhases([]); + Assert.Empty(phases); + } + + [Fact] + public async Task Run_SkipsWhenLlmFails() + { + var now = DateTime.UtcNow; + for (int i = 0; i < 4; i++) + { + await _obsStore.Add(new Observation + { + Id = $"obs-{i}", SessionId = "session-fail", ThreadId = "t1", + Timestamp = now.AddMinutes(-20 + i * 5), Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = $"Activity {i}" + }); + } + + var ctx = CreateContext(new FailingLlmService()); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + Assert.Null(await _sessionStore.GetBySessionId("session-fail")); + } + [Fact] public void DetectTurningPoints_FindsDecisionsAndResolutions() { @@ -158,7 +220,7 @@ public void DetectTurningPoints_FindsDecisionsAndResolutions() var points = StorytellerAgent.DetectTurningPoints(observations); Assert.Contains(points, p => p.Contains("Decision")); - Assert.Contains(points, p => p.Contains("resolved")); + Assert.Contains(points, p => p.Contains("no recurrence")); } private static Observation MakeObs(string id, DateTime timestamp, EventType type, @@ -189,4 +251,16 @@ public Task Submit(LlmTask task, CancellationToken ct = default) public Task Embed(string text, CancellationToken ct = default) => Task.FromResult(Array.Empty()); } + + private class FailingLlmService : ILlmService + { + public bool IsLocalAvailable => false; + public bool IsCloudAvailable => false; + public int CloudRequestsToday => 0; + public int QueueDepth => 0; + public Task Submit(LlmTask task, CancellationToken ct = default) + => Task.FromResult(new LlmResult { TaskId = task.Id, Success = false, Content = null }); + public Task Embed(string text, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + } } From 34b619ac848789bf238d63ed3d775b9d60c2e5d9 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:16:56 -0700 Subject: [PATCH 16/36] =?UTF-8?q?feat:=20add=20Decision=20Replay=20?= =?UTF-8?q?=E2=80=94=20query=20service,=20API,=20CLI,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DecisionChainBuilder: traverses causal graph edges to build chronological decision chains for files and decision nodes - ReplayEndpoints: GET /replay/file/{path}, GET /replay/decision/{nodeId} - ReplayCommand CLI: devbrain replay [--decision ] - Dashboard Replay page with vertical timeline visualization, color-coded step types, file tags, and narrative summary - 5 new tests for DecisionChainBuilder (chain building, dead ends, empty file, causal traversal, nonexistent node) - Wire DecisionChainBuilder in Program.cs Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/App.tsx | 2 + dashboard/src/api/client.ts | 25 +++ dashboard/src/components/Navigation.tsx | 1 + dashboard/src/pages/Replay.tsx | 201 ++++++++++++++++++ src/DevBrain.Api/Endpoints/ReplayEndpoints.cs | 29 +++ src/DevBrain.Api/Program.cs | 2 + src/DevBrain.Cli/Commands/ReplayCommand.cs | 113 ++++++++++ src/DevBrain.Cli/Program.cs | 1 + src/DevBrain.Storage/DecisionChainBuilder.cs | 131 ++++++++++++ .../DecisionChainBuilderTests.cs | 144 +++++++++++++ 10 files changed, 649 insertions(+) create mode 100644 dashboard/src/pages/Replay.tsx create mode 100644 src/DevBrain.Api/Endpoints/ReplayEndpoints.cs create mode 100644 src/DevBrain.Cli/Commands/ReplayCommand.cs create mode 100644 src/DevBrain.Storage/DecisionChainBuilder.cs create mode 100644 tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 5c0c6e2..04a84dc 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -12,6 +12,7 @@ import Database from './pages/Database'; import Setup from './pages/Setup'; import Alerts from './pages/Alerts'; import Sessions from './pages/Sessions'; +import Replay from './pages/Replay'; export default function App() { return ( @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 16f6e5b..d893435 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -197,6 +197,24 @@ export interface DejaVuAlert { createdAt: string; } +// DecisionChain model +export interface DecisionStep { + observationId: string; + summary: string; + timestamp: string; + stepType: string; + filesInvolved: string[]; + alternativesRejected: string[]; +} + +export interface DecisionChain { + id: string; + rootNodeId: string; + narrative: string; + steps: DecisionStep[]; + generatedAt: string; +} + // SessionSummary model export interface SessionSummary { id: string; @@ -369,6 +387,13 @@ export const api = { if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`); }, + // Decision Replay + replayFile: (path: string) => + fetchJson(`/replay/file/${encodeURIComponent(path)}`), + + replayDecision: (nodeId: string) => + fetchJson(`/replay/decision/${encodeURIComponent(nodeId)}`), + // Sessions sessions: (limit = 50) => fetchJson(`/sessions?limit=${limit}`), diff --git a/dashboard/src/components/Navigation.tsx b/dashboard/src/components/Navigation.tsx index 8a45cc6..05599ac 100644 --- a/dashboard/src/components/Navigation.tsx +++ b/dashboard/src/components/Navigation.tsx @@ -6,6 +6,7 @@ const links = [ { to: '/dead-ends', label: 'Dead Ends' }, { to: '/alerts', label: 'Alerts' }, { to: '/sessions', label: 'Sessions' }, + { to: '/replay', label: 'Replay' }, { to: '/threads', label: 'Threads' }, { to: '/search', label: 'Search' }, { to: '/settings', label: 'Settings' }, diff --git a/dashboard/src/pages/Replay.tsx b/dashboard/src/pages/Replay.tsx new file mode 100644 index 0000000..259b18f --- /dev/null +++ b/dashboard/src/pages/Replay.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import { api, type DecisionChain } from '../api/client'; + +const stepColors: Record = { + Decision: '#22c55e', + DeadEnd: '#ef4444', + Error: '#eab308', + Resolution: '#3b82f6', +}; + +export default function Replay() { + const [query, setQuery] = useState(''); + const [chain, setChain] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const search = async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + setChain(null); + + try { + const result = await api.replayFile(query.trim()); + setChain(result); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('404')) { + setError(`No decision chain found for '${query}'`); + } else { + setError(msg); + } + } finally { + setLoading(false); + } + }; + + return ( +
+

Decision Replay

+

+ Enter a file path to see why it exists — the full chain of decisions, + dead ends, and resolutions. +

+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && search()} + style={styles.input} + /> + +
+ + {error &&
{error}
} + + {chain && ( +
+
{chain.narrative}
+ +
+ {chain.steps.map((step, i) => ( +
+
+
+ {i < chain.steps.length - 1 && ( +
+ )} +
+
+
+ + {step.stepType} + + + {new Date(step.timestamp).toLocaleString()} + +
+
{step.summary}
+ {step.filesInvolved.length > 0 && ( +
+ {step.filesInvolved.map((f) => ( + + {f} + + ))} +
+ )} +
+
+ ))} +
+
+ )} +
+ ); +} + +const styles: Record = { + container: { padding: '1.5rem', maxWidth: 800, margin: '0 auto' }, + subtitle: { color: '#9ca3af', fontSize: '0.9rem', marginBottom: '1.5rem' }, + searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }, + input: { + flex: 1, + padding: '0.5rem 0.75rem', + background: '#1f2028', + border: '1px solid #2e303a', + borderRadius: 6, + color: '#f3f4f6', + fontSize: '0.9rem', + fontFamily: 'monospace', + outline: 'none', + }, + searchBtn: { + padding: '0.5rem 1.25rem', + background: '#2a2a4a', + color: '#a5b4fc', + border: '1px solid #3b3b6b', + borderRadius: 6, + cursor: 'pointer', + fontSize: '0.9rem', + }, + error: { color: '#ef4444', marginBottom: '1rem' }, + result: {}, + narrative: { + padding: '1rem', + background: '#161620', + borderRadius: 6, + color: '#d1d5db', + fontSize: '0.9rem', + lineHeight: 1.5, + marginBottom: '1.5rem', + }, + timeline: { display: 'flex', flexDirection: 'column' }, + step: { display: 'flex', gap: '1rem' }, + stepLine: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: 20, + flexShrink: 0, + }, + stepDot: { + width: 12, + height: 12, + borderRadius: '50%', + flexShrink: 0, + }, + stepConnector: { + width: 2, + flex: 1, + background: '#374151', + minHeight: 30, + }, + stepContent: { + flex: 1, + paddingBottom: '1.5rem', + }, + stepHeader: { + display: 'flex', + gap: '0.75rem', + alignItems: 'center', + marginBottom: '0.25rem', + }, + stepType: { + fontSize: '0.75rem', + fontWeight: 700, + textTransform: 'uppercase' as const, + }, + stepTime: { fontSize: '0.75rem', color: '#6b7280' }, + stepSummary: { color: '#f3f4f6', fontSize: '0.9rem', lineHeight: 1.4 }, + stepFiles: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '0.3rem', + marginTop: '0.4rem', + }, + fileTag: { + fontSize: '0.7rem', + background: '#2a2a4a', + color: '#a5b4fc', + padding: '2px 6px', + borderRadius: 3, + fontFamily: 'monospace', + }, +}; diff --git a/src/DevBrain.Api/Endpoints/ReplayEndpoints.cs b/src/DevBrain.Api/Endpoints/ReplayEndpoints.cs new file mode 100644 index 0000000..728fce3 --- /dev/null +++ b/src/DevBrain.Api/Endpoints/ReplayEndpoints.cs @@ -0,0 +1,29 @@ +namespace DevBrain.Api.Endpoints; + +using DevBrain.Storage; + +public static class ReplayEndpoints +{ + public static void MapReplayEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/replay"); + + // Decision chain for a file + group.MapGet("/file/{*path}", async (string path, DecisionChainBuilder builder, int? hops) => + { + var chain = await builder.BuildForFile(path, hops ?? 3); + return chain is not null + ? Results.Ok(chain) + : Results.NotFound(new { error = $"No decision chain found for '{path}'" }); + }); + + // Decision chain from a specific graph node + group.MapGet("/decision/{nodeId}", async (string nodeId, DecisionChainBuilder builder, int? hops) => + { + var chain = await builder.BuildForDecision(nodeId, hops ?? 4); + return chain is not null + ? Results.Ok(chain) + : Results.NotFound(new { error = $"No decision chain found for node '{nodeId}'" }); + }); + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 64bee56..6cb5e0a 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -113,6 +113,7 @@ builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(sessionStore); +builder.Services.AddSingleton(new DecisionChainBuilder(graphStore, observationStore)); builder.Services.AddSingleton(llmService); builder.Services.AddSingleton(llmService); // concrete type for ResetDailyCounter builder.Services.AddSingleton(eventBus); @@ -155,6 +156,7 @@ app.MapDeadEndEndpoints(); app.MapAlertEndpoints(); app.MapSessionEndpoints(); +app.MapReplayEndpoints(); app.MapContextEndpoints(); app.MapDatabaseEndpoints(); app.MapSetupEndpoints(); diff --git a/src/DevBrain.Cli/Commands/ReplayCommand.cs b/src/DevBrain.Cli/Commands/ReplayCommand.cs new file mode 100644 index 0000000..4bf6ec8 --- /dev/null +++ b/src/DevBrain.Cli/Commands/ReplayCommand.cs @@ -0,0 +1,113 @@ +using System.CommandLine; +using System.Text.Json; +using DevBrain.Cli.Output; + +namespace DevBrain.Cli.Commands; + +public class ReplayCommand : Command +{ + private readonly Argument _pathArg = new("path") + { + Description = "File path to get the decision chain for" + }; + + private readonly Option _decisionOption = new("--decision") + { + Description = "Graph node ID of a specific decision to trace" + }; + + public ReplayCommand() : base("replay", "Show the decision chain for a file or decision") + { + Add(_pathArg); + Add(_decisionOption); + SetAction(Execute); + } + + private async Task Execute(ParseResult pr) + { + var path = pr.GetValue(_pathArg); + var decisionId = pr.GetValue(_decisionOption); + var client = new DevBrainHttpClient(); + + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + + try + { + JsonElement json; + + if (!string.IsNullOrEmpty(decisionId)) + { + json = await client.GetJson($"/api/v1/replay/decision/{Uri.EscapeDataString(decisionId)}"); + } + else if (!string.IsNullOrEmpty(path)) + { + json = await client.GetJson($"/api/v1/replay/file/{Uri.EscapeDataString(path)}"); + } + else + { + ConsoleFormatter.PrintError("Provide a file path or --decision ."); + return; + } + + var narrative = json.GetPropertyOrDefault("narrative", ""); + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(" Decision Chain"); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine($" {narrative}"); + Console.WriteLine(); + + if (json.TryGetProperty("steps", out var steps) && steps.ValueKind == JsonValueKind.Array) + { + foreach (var step in steps.EnumerateArray()) + { + var stepType = step.GetPropertyOrDefault("stepType", "Decision"); + var summary = step.GetPropertyOrDefault("summary", "(no summary)"); + var timestamp = step.GetPropertyOrDefault("timestamp", ""); + + var color = stepType switch + { + "Decision" => ConsoleColor.Green, + "DeadEnd" => ConsoleColor.Red, + "Error" => ConsoleColor.Yellow, + "Resolution" => ConsoleColor.Blue, + _ => ConsoleColor.Gray + }; + + Console.ForegroundColor = color; + Console.Write($" [{stepType}] "); + Console.ResetColor(); + + if (!string.IsNullOrEmpty(timestamp) && DateTime.TryParse(timestamp, out var dt)) + Console.Write($"{dt:yyyy-MM-dd HH:mm} "); + + Console.WriteLine(summary); + + if (step.TryGetProperty("filesInvolved", out var files) && files.ValueKind == JsonValueKind.Array) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + foreach (var f in files.EnumerateArray()) + Console.WriteLine($" {f.GetString()}"); + Console.ResetColor(); + } + } + } + + Console.WriteLine(); + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + ConsoleFormatter.PrintWarning("No decision chain found for this file."); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to fetch decision chain: {ex.Message}"); + } + } +} diff --git a/src/DevBrain.Cli/Program.cs b/src/DevBrain.Cli/Program.cs index 4f595f2..5377175 100644 --- a/src/DevBrain.Cli/Program.cs +++ b/src/DevBrain.Cli/Program.cs @@ -14,6 +14,7 @@ root.Add(new DeadEndsCommand()); root.Add(new AlertsCommand()); root.Add(new StoryCommand()); +root.Add(new ReplayCommand()); root.Add(new RelatedCommand()); root.Add(new AgentsCommand()); root.Add(new ConfigCommand()); diff --git a/src/DevBrain.Storage/DecisionChainBuilder.cs b/src/DevBrain.Storage/DecisionChainBuilder.cs new file mode 100644 index 0000000..fb67bfa --- /dev/null +++ b/src/DevBrain.Storage/DecisionChainBuilder.cs @@ -0,0 +1,131 @@ +namespace DevBrain.Storage; + +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class DecisionChainBuilder +{ + private readonly IGraphStore _graph; + private readonly IObservationStore _observations; + + private static readonly List CausalEdgeTypes = ["caused_by", "supersedes", "resolved_by"]; + + public DecisionChainBuilder(IGraphStore graph, IObservationStore observations) + { + _graph = graph; + _observations = observations; + } + + public async Task BuildForFile(string filePath, int maxHops = 3) + { + var related = await _graph.GetRelatedToFile(filePath); + var decisionNodes = related + .Where(n => n.Type is "Decision" or "Bug") + .ToList(); + + if (decisionNodes.Count == 0) + return null; + + var allNodes = new Dictionary(); + foreach (var node in decisionNodes) + { + allNodes.TryAdd(node.Id, node); + + var causalNeighbors = await _graph.GetNeighbors(node.Id, maxHops, CausalEdgeTypes); + foreach (var neighbor in causalNeighbors) + { + if (neighbor.Type is "Decision" or "Bug") + allNodes.TryAdd(neighbor.Id, neighbor); + } + } + + var steps = await BuildSteps(allNodes.Values); + if (steps.Count == 0) + return null; + + return new DecisionChain + { + Id = Guid.NewGuid().ToString(), + RootNodeId = decisionNodes[0].Id, + Narrative = BuildNarrativePlaceholder(filePath, steps), + Steps = steps + }; + } + + public async Task BuildForDecision(string nodeId, int maxHops = 4) + { + var rootNode = await _graph.GetNode(nodeId); + if (rootNode is null) + return null; + + var allNodes = new Dictionary { [rootNode.Id] = rootNode }; + var causalNeighbors = await _graph.GetNeighbors(rootNode.Id, maxHops, CausalEdgeTypes); + foreach (var neighbor in causalNeighbors) + { + if (neighbor.Type is "Decision" or "Bug") + allNodes.TryAdd(neighbor.Id, neighbor); + } + + var steps = await BuildSteps(allNodes.Values); + if (steps.Count == 0) + return null; + + return new DecisionChain + { + Id = Guid.NewGuid().ToString(), + RootNodeId = rootNode.Id, + Narrative = BuildNarrativePlaceholder(rootNode.Name, steps), + Steps = steps + }; + } + + private async Task> BuildSteps(IEnumerable nodes) + { + var steps = new List(); + + foreach (var node in nodes) + { + Observation? obs = null; + if (node.SourceId is not null) + obs = await _observations.GetById(node.SourceId); + + var stepType = node.Type switch + { + "Decision" => DecisionStepType.Decision, + "Bug" => DecisionStepType.DeadEnd, + _ => DecisionStepType.Decision + }; + + steps.Add(new DecisionStep + { + ObservationId = node.SourceId ?? node.Id, + Summary = node.Name, + Timestamp = obs?.Timestamp ?? node.CreatedAt, + StepType = stepType, + FilesInvolved = obs?.FilesInvolved ?? [] + }); + } + + return steps.OrderBy(s => s.Timestamp).ToList(); + } + + private static string BuildNarrativePlaceholder(string root, IReadOnlyList steps) + { + var decisions = steps.Where(s => s.StepType == DecisionStepType.Decision).ToList(); + var deadEnds = steps.Where(s => s.StepType == DecisionStepType.DeadEnd).ToList(); + + var parts = new List + { + $"Decision chain for '{root}' spans {steps.Count} step(s)." + }; + + if (decisions.Count > 0) + parts.Add($"{decisions.Count} decision(s): {string.Join("; ", decisions.Select(d => d.Summary))}."); + + if (deadEnds.Count > 0) + parts.Add($"{deadEnds.Count} dead end(s) encountered along the way."); + + return string.Join(" ", parts); + } +} diff --git a/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs b/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs new file mode 100644 index 0000000..ffd2540 --- /dev/null +++ b/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs @@ -0,0 +1,144 @@ +using DevBrain.Core.Enums; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class DecisionChainBuilderTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private DecisionChainBuilder _builder = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _builder = new DecisionChainBuilder(_graphStore, _obsStore); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task BuildForFile_ReturnsChainWithDecisions() + { + // Create file node + two decision nodes linked by causal edge + var fileNode = await _graphStore.AddNode("File", "src/Storage.cs"); + var dec1 = await _graphStore.AddNode("Decision", "Use SQLite", sourceId: "obs-1"); + var dec2 = await _graphStore.AddNode("Decision", "Add WAL mode", sourceId: "obs-2"); + + await _graphStore.AddEdge(dec1.Id, fileNode.Id, "references"); + await _graphStore.AddEdge(dec2.Id, fileNode.Id, "references"); + await _graphStore.AddEdge(dec2.Id, dec1.Id, "caused_by"); + + // Create backing observations + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-2), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Decided to use SQLite", FilesInvolved = ["src/Storage.cs"] + }); + await _obsStore.Add(new Observation + { + Id = "obs-2", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-1), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Added WAL mode", FilesInvolved = ["src/Storage.cs"] + }); + + var chain = await _builder.BuildForFile("src/Storage.cs"); + + Assert.NotNull(chain); + Assert.Equal(2, chain.Steps.Count); + Assert.Equal("Use SQLite", chain.Steps[0].Summary); // chronological order + Assert.Equal("Add WAL mode", chain.Steps[1].Summary); + Assert.All(chain.Steps, s => Assert.Equal(DecisionStepType.Decision, s.StepType)); + } + + [Fact] + public async Task BuildForFile_IncludesDeadEndNodes() + { + var fileNode = await _graphStore.AddNode("File", "src/Search.cs"); + var dec = await _graphStore.AddNode("Decision", "Use FTS5", sourceId: "obs-1"); + var bug = await _graphStore.AddNode("Bug", "FTS tokenizer issue", sourceId: "de-1"); + + await _graphStore.AddEdge(dec.Id, fileNode.Id, "references"); + await _graphStore.AddEdge(bug.Id, fileNode.Id, "references"); + await _graphStore.AddEdge(dec.Id, bug.Id, "resolved_by"); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow, + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Use FTS5", FilesInvolved = ["src/Search.cs"] + }); + + var chain = await _builder.BuildForFile("src/Search.cs"); + + Assert.NotNull(chain); + Assert.Contains(chain.Steps, s => s.StepType == DecisionStepType.DeadEnd); + Assert.Contains(chain.Steps, s => s.StepType == DecisionStepType.Decision); + } + + [Fact] + public async Task BuildForFile_ReturnsNullWhenNoDecisions() + { + var fileNode = await _graphStore.AddNode("File", "src/Empty.cs"); + + var chain = await _builder.BuildForFile("src/Empty.cs"); + + Assert.Null(chain); + } + + [Fact] + public async Task BuildForDecision_TraversesCausalChain() + { + var dec1 = await _graphStore.AddNode("Decision", "Choose SQLite", sourceId: "obs-1"); + var dec2 = await _graphStore.AddNode("Decision", "Add WAL", sourceId: "obs-2"); + var dec3 = await _graphStore.AddNode("Decision", "Add connection pooling", sourceId: "obs-3"); + + await _graphStore.AddEdge(dec2.Id, dec1.Id, "caused_by"); + await _graphStore.AddEdge(dec3.Id, dec2.Id, "caused_by"); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-3), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Choose SQLite" + }); + await _obsStore.Add(new Observation + { + Id = "obs-2", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-2), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Add WAL" + }); + await _obsStore.Add(new Observation + { + Id = "obs-3", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-1), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Add connection pooling" + }); + + var chain = await _builder.BuildForDecision(dec3.Id); + + Assert.NotNull(chain); + Assert.Equal(3, chain.Steps.Count); + Assert.Equal("Choose SQLite", chain.Steps[0].Summary); + Assert.Equal("Add connection pooling", chain.Steps[2].Summary); + } + + [Fact] + public async Task BuildForDecision_ReturnsNullForNonexistentNode() + { + var chain = await _builder.BuildForDecision("nonexistent-id"); + Assert.Null(chain); + } +} From 535c1ac85df4979195332eecf6b2110f1d7d41d1 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:05:51 -0700 Subject: [PATCH 17/36] fix: address Decision Replay review findings Critical: - BuildForDecision rejects non-Decision/Bug root nodes (returns null) - Extract TraverseCausalGraph() method for Blast Radius reuse - CausalEdgeTypes now IReadOnlyList (immutable) - BuildSteps type filter prevents non-decision nodes from leaking - Batch observation lookups via obsMap (eliminates N+1 queries) High: - Remove unpopulated AlternativesRejected from DecisionStep model - RootNodeId now deterministic (chronologically earliest step) Medium: - CLI path argument is now optional when using --decision - Dashboard error handling improved (structured check vs string match) - 3 new tests: non-Decision root rejection, deterministic root, hops limit verification All 87 tests passing, dashboard builds clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/api/client.ts | 1 - dashboard/src/pages/Replay.tsx | 10 +- src/DevBrain.Cli/Commands/ReplayCommand.cs | 5 +- src/DevBrain.Core/Models/DecisionChain.cs | 1 - src/DevBrain.Storage/DecisionChainBuilder.cs | 98 +++++++++++++------ .../DecisionChainBuilderTests.cs | 76 ++++++++++++++ 6 files changed, 150 insertions(+), 41 deletions(-) diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index d893435..9b6d6b7 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -204,7 +204,6 @@ export interface DecisionStep { timestamp: string; stepType: string; filesInvolved: string[]; - alternativesRejected: string[]; } export interface DecisionChain { diff --git a/dashboard/src/pages/Replay.tsx b/dashboard/src/pages/Replay.tsx index 259b18f..063b6bb 100644 --- a/dashboard/src/pages/Replay.tsx +++ b/dashboard/src/pages/Replay.tsx @@ -25,11 +25,11 @@ export default function Replay() { setChain(result); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - if (msg.includes('404')) { - setError(`No decision chain found for '${query}'`); - } else { - setError(msg); - } + setError( + msg.includes('404') || msg.includes('Not Found') + ? `No decision chain found for '${query}'` + : `Error: ${msg}` + ); } finally { setLoading(false); } diff --git a/src/DevBrain.Cli/Commands/ReplayCommand.cs b/src/DevBrain.Cli/Commands/ReplayCommand.cs index 4bf6ec8..3494645 100644 --- a/src/DevBrain.Cli/Commands/ReplayCommand.cs +++ b/src/DevBrain.Cli/Commands/ReplayCommand.cs @@ -6,9 +6,10 @@ namespace DevBrain.Cli.Commands; public class ReplayCommand : Command { - private readonly Argument _pathArg = new("path") + private readonly Argument _pathArg = new("path") { - Description = "File path to get the decision chain for" + Description = "File path to get the decision chain for", + Arity = ArgumentArity.ZeroOrOne }; private readonly Option _decisionOption = new("--decision") diff --git a/src/DevBrain.Core/Models/DecisionChain.cs b/src/DevBrain.Core/Models/DecisionChain.cs index b6bf0cf..4e6e79c 100644 --- a/src/DevBrain.Core/Models/DecisionChain.cs +++ b/src/DevBrain.Core/Models/DecisionChain.cs @@ -18,5 +18,4 @@ public record DecisionStep public required DateTime Timestamp { get; init; } public required DecisionStepType StepType { get; init; } public IReadOnlyList FilesInvolved { get; init; } = []; - public IReadOnlyList AlternativesRejected { get; init; } = []; } diff --git a/src/DevBrain.Storage/DecisionChainBuilder.cs b/src/DevBrain.Storage/DecisionChainBuilder.cs index fb67bfa..5cbab04 100644 --- a/src/DevBrain.Storage/DecisionChainBuilder.cs +++ b/src/DevBrain.Storage/DecisionChainBuilder.cs @@ -9,7 +9,8 @@ public class DecisionChainBuilder private readonly IGraphStore _graph; private readonly IObservationStore _observations; - private static readonly List CausalEdgeTypes = ["caused_by", "supersedes", "resolved_by"]; + private static readonly IReadOnlyList CausalEdgeTypes = ["caused_by", "supersedes", "resolved_by"]; + private static readonly HashSet DecisionNodeTypes = ["Decision", "Bug"]; public DecisionChainBuilder(IGraphStore graph, IObservationStore observations) { @@ -17,37 +18,55 @@ public DecisionChainBuilder(IGraphStore graph, IObservationStore observations) _observations = observations; } - public async Task BuildForFile(string filePath, int maxHops = 3) + /// + /// Traverse causal edges from seed nodes outward. Reusable by Blast Radius. + /// + public async Task> TraverseCausalGraph( + IEnumerable seedNodes, int maxHops, + IReadOnlyList? edgeTypes = null, + HashSet? nodeTypeFilter = null) { - var related = await _graph.GetRelatedToFile(filePath); - var decisionNodes = related - .Where(n => n.Type is "Decision" or "Bug") - .ToList(); - - if (decisionNodes.Count == 0) - return null; - + var types = edgeTypes ?? CausalEdgeTypes; + var filter = nodeTypeFilter ?? DecisionNodeTypes; var allNodes = new Dictionary(); - foreach (var node in decisionNodes) + + foreach (var node in seedNodes) { - allNodes.TryAdd(node.Id, node); + if (filter.Contains(node.Type)) + allNodes.TryAdd(node.Id, node); - var causalNeighbors = await _graph.GetNeighbors(node.Id, maxHops, CausalEdgeTypes); - foreach (var neighbor in causalNeighbors) + var neighbors = await _graph.GetNeighbors(node.Id, maxHops, types); + foreach (var neighbor in neighbors) { - if (neighbor.Type is "Decision" or "Bug") + if (filter.Contains(neighbor.Type)) allNodes.TryAdd(neighbor.Id, neighbor); } } + return allNodes; + } + + public async Task BuildForFile(string filePath, int maxHops = 3) + { + var related = await _graph.GetRelatedToFile(filePath); + var seedNodes = related.Where(n => DecisionNodeTypes.Contains(n.Type)).ToList(); + + if (seedNodes.Count == 0) + return null; + + var allNodes = await TraverseCausalGraph(seedNodes, maxHops); var steps = await BuildSteps(allNodes.Values); + if (steps.Count == 0) return null; + // Deterministic root: chronologically earliest step + var rootNodeId = steps[0].ObservationId; + return new DecisionChain { Id = Guid.NewGuid().ToString(), - RootNodeId = decisionNodes[0].Id, + RootNodeId = rootNodeId, Narrative = BuildNarrativePlaceholder(filePath, steps), Steps = steps }; @@ -59,15 +78,13 @@ public DecisionChainBuilder(IGraphStore graph, IObservationStore observations) if (rootNode is null) return null; - var allNodes = new Dictionary { [rootNode.Id] = rootNode }; - var causalNeighbors = await _graph.GetNeighbors(rootNode.Id, maxHops, CausalEdgeTypes); - foreach (var neighbor in causalNeighbors) - { - if (neighbor.Type is "Decision" or "Bug") - allNodes.TryAdd(neighbor.Id, neighbor); - } + // Reject non-Decision/Bug root nodes + if (!DecisionNodeTypes.Contains(rootNode.Type)) + return null; + var allNodes = await TraverseCausalGraph([rootNode], maxHops); var steps = await BuildSteps(allNodes.Values); + if (steps.Count == 0) return null; @@ -82,20 +99,37 @@ public DecisionChainBuilder(IGraphStore graph, IObservationStore observations) private async Task> BuildSteps(IEnumerable nodes) { - var steps = new List(); + var nodeList = nodes.ToList(); - foreach (var node in nodes) + // Batch-fetch observations to avoid N+1 queries + var sourceIds = nodeList + .Where(n => n.SourceId is not null) + .Select(n => n.SourceId!) + .Distinct() + .ToList(); + + var obsMap = new Dictionary(); + foreach (var id in sourceIds) { + var obs = await _observations.GetById(id); + if (obs is not null) + obsMap[id] = obs; + } + + var steps = new List(); + foreach (var node in nodeList) + { + // Skip non-Decision/Bug nodes + if (!DecisionNodeTypes.Contains(node.Type)) + continue; + Observation? obs = null; if (node.SourceId is not null) - obs = await _observations.GetById(node.SourceId); + obsMap.TryGetValue(node.SourceId, out obs); - var stepType = node.Type switch - { - "Decision" => DecisionStepType.Decision, - "Bug" => DecisionStepType.DeadEnd, - _ => DecisionStepType.Decision - }; + var stepType = node.Type == "Bug" + ? DecisionStepType.DeadEnd + : DecisionStepType.Decision; steps.Add(new DecisionStep { diff --git a/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs b/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs index ffd2540..d464e63 100644 --- a/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs +++ b/tests/DevBrain.Storage.Tests/DecisionChainBuilderTests.cs @@ -141,4 +141,80 @@ public async Task BuildForDecision_ReturnsNullForNonexistentNode() var chain = await _builder.BuildForDecision("nonexistent-id"); Assert.Null(chain); } + + [Fact] + public async Task BuildForDecision_RejectsNonDecisionNodeType() + { + var fileNode = await _graphStore.AddNode("File", "src/Program.cs"); + + var chain = await _builder.BuildForDecision(fileNode.Id); + + Assert.Null(chain); + } + + [Fact] + public async Task BuildForFile_RootNodeIdIsChronologicallyEarliest() + { + var fileNode = await _graphStore.AddNode("File", "src/App.cs"); + var dec1 = await _graphStore.AddNode("Decision", "Early decision", sourceId: "obs-early"); + var dec2 = await _graphStore.AddNode("Decision", "Late decision", sourceId: "obs-late"); + + await _graphStore.AddEdge(dec1.Id, fileNode.Id, "references"); + await _graphStore.AddEdge(dec2.Id, fileNode.Id, "references"); + + await _obsStore.Add(new Observation + { + Id = "obs-early", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-5), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Early", FilesInvolved = ["src/App.cs"] + }); + await _obsStore.Add(new Observation + { + Id = "obs-late", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-1), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Late", FilesInvolved = ["src/App.cs"] + }); + + var chain = await _builder.BuildForFile("src/App.cs"); + + Assert.NotNull(chain); + Assert.Equal("obs-early", chain.RootNodeId); + } + + [Fact] + public async Task BuildForDecision_HopsLimitsTraversalDepth() + { + var dec1 = await _graphStore.AddNode("Decision", "Root", sourceId: "obs-1"); + var dec2 = await _graphStore.AddNode("Decision", "Hop 1", sourceId: "obs-2"); + var dec3 = await _graphStore.AddNode("Decision", "Hop 2", sourceId: "obs-3"); + + await _graphStore.AddEdge(dec2.Id, dec1.Id, "caused_by"); + await _graphStore.AddEdge(dec3.Id, dec2.Id, "caused_by"); + + await _obsStore.Add(new Observation + { + Id = "obs-1", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-3), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Root" + }); + await _obsStore.Add(new Observation + { + Id = "obs-2", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-2), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Hop 1" + }); + await _obsStore.Add(new Observation + { + Id = "obs-3", SessionId = "s1", Timestamp = DateTime.UtcNow.AddHours(-1), + Project = "proj", EventType = EventType.Decision, Source = CaptureSource.ClaudeCode, + RawContent = "Hop 2" + }); + + // With hops=1, should only reach dec1 (direct neighbor) + var chain = await _builder.BuildForDecision(dec3.Id, maxHops: 1); + + Assert.NotNull(chain); + // dec3 + dec2 (1 hop away) but NOT dec1 (2 hops away) + Assert.Equal(2, chain.Steps.Count); + } } From fd8affe58e26610c067c81f3f840e8194cd5c0a4 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:25:12 -0700 Subject: [PATCH 18/36] =?UTF-8?q?feat:=20add=20Blast=20Radius=20Prediction?= =?UTF-8?q?=20=E2=80=94=20calculator,=20API,=20CLI,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BlastRadiusCalculator: reuses TraverseCausalGraph from DecisionChainBuilder, collects downstream files, computes risk scores per spec formula - Risk = (1/chainLength) * deadEndMultiplier * recencyDecay - BlastRadiusEndpoints: GET /blast-radius/{path}?hops=N (max 5) - BlastCommand CLI: devbrain blast with color-coded risk output - Dashboard BlastRadius page with risk bars, dead-end warnings, file cards - 8 new tests: affected files, source exclusion, dead ends, unknown file, causal chain traversal, risk score unit tests (3) - InternalsVisibleTo for Storage.Tests Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/App.tsx | 2 + dashboard/src/api/client.ts | 21 ++ dashboard/src/components/Navigation.tsx | 1 + dashboard/src/pages/BlastRadius.tsx | 193 ++++++++++++++++++ .../Endpoints/BlastRadiusEndpoints.cs | 18 ++ src/DevBrain.Api/Program.cs | 5 +- src/DevBrain.Cli/Commands/BlastCommand.cs | 96 +++++++++ src/DevBrain.Cli/Program.cs | 1 + src/DevBrain.Storage/BlastRadiusCalculator.cs | 103 ++++++++++ src/DevBrain.Storage/DevBrain.Storage.csproj | 4 + .../BlastRadiusCalculatorTests.cs | 144 +++++++++++++ 11 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/pages/BlastRadius.tsx create mode 100644 src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs create mode 100644 src/DevBrain.Cli/Commands/BlastCommand.cs create mode 100644 src/DevBrain.Storage/BlastRadiusCalculator.cs create mode 100644 tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 04a84dc..5b4ed52 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import Setup from './pages/Setup'; import Alerts from './pages/Alerts'; import Sessions from './pages/Sessions'; import Replay from './pages/Replay'; +import BlastRadius from './pages/BlastRadius'; export default function App() { return ( @@ -33,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 9b6d6b7..823d2bb 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -197,6 +197,23 @@ export interface DejaVuAlert { createdAt: string; } +// BlastRadius model +export interface BlastRadiusEntry { + filePath: string; + riskScore: number; + chainLength: number; + reason: string; + decisionChain: string[]; +} + +export interface BlastRadius { + sourceFile: string; + affectedFiles: BlastRadiusEntry[]; + deadEndsAtRisk: string[]; + summary?: string; + generatedAt: string; +} + // DecisionChain model export interface DecisionStep { observationId: string; @@ -386,6 +403,10 @@ export const api = { if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`); }, + // Blast Radius + blastRadius: (path: string, hops = 3) => + fetchJson(`/blast-radius/${encodeURIComponent(path)}?hops=${hops}`), + // Decision Replay replayFile: (path: string) => fetchJson(`/replay/file/${encodeURIComponent(path)}`), diff --git a/dashboard/src/components/Navigation.tsx b/dashboard/src/components/Navigation.tsx index 05599ac..8a40732 100644 --- a/dashboard/src/components/Navigation.tsx +++ b/dashboard/src/components/Navigation.tsx @@ -7,6 +7,7 @@ const links = [ { to: '/alerts', label: 'Alerts' }, { to: '/sessions', label: 'Sessions' }, { to: '/replay', label: 'Replay' }, + { to: '/blast-radius', label: 'Blast Radius' }, { to: '/threads', label: 'Threads' }, { to: '/search', label: 'Search' }, { to: '/settings', label: 'Settings' }, diff --git a/dashboard/src/pages/BlastRadius.tsx b/dashboard/src/pages/BlastRadius.tsx new file mode 100644 index 0000000..9ae2120 --- /dev/null +++ b/dashboard/src/pages/BlastRadius.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { api, type BlastRadius as BlastRadiusType } from '../api/client'; + +function riskColor(score: number): string { + if (score > 0.7) return '#ef4444'; + if (score > 0.3) return '#eab308'; + return '#22c55e'; +} + +export default function BlastRadius() { + const [query, setQuery] = useState(''); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const search = async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + setResult(null); + + try { + const data = await api.blastRadius(query.trim()); + setResult(data); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return ( +
+

Blast Radius

+

+ Enter a file path to see what else might break if you change it — + based on decision dependencies, not just code imports. +

+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && search()} + style={styles.input} + /> + +
+ + {error &&
{error}
} + + {result && ( +
+ {result.deadEndsAtRisk.length > 0 && ( +
+ {result.deadEndsAtRisk.length} dead end(s) at risk of + re-triggering +
+ )} + + {result.affectedFiles.length === 0 ? ( +

+ No affected files found. Safe to change! +

+ ) : ( +
+
+ {result.affectedFiles.length} affected file(s) +
+ {result.affectedFiles.map((file) => ( +
+
+
+
+ + {(file.riskScore * 100).toFixed(0)}% + +
+ + chain: {file.chainLength} + +
+
{file.filePath}
+
{file.reason}
+
+ ))} +
+ )} +
+ )} +
+ ); +} + +const styles: Record = { + container: { padding: '1.5rem', maxWidth: 800, margin: '0 auto' }, + subtitle: { color: '#9ca3af', fontSize: '0.9rem', marginBottom: '1.5rem' }, + searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }, + input: { + flex: 1, + padding: '0.5rem 0.75rem', + background: '#1f2028', + border: '1px solid #2e303a', + borderRadius: 6, + color: '#f3f4f6', + fontSize: '0.9rem', + fontFamily: 'monospace', + outline: 'none', + }, + searchBtn: { + padding: '0.5rem 1.25rem', + background: '#2a2a4a', + color: '#a5b4fc', + border: '1px solid #3b3b6b', + borderRadius: 6, + cursor: 'pointer', + fontSize: '0.9rem', + }, + error: { color: '#ef4444', marginBottom: '1rem' }, + deadEndWarning: { + background: '#7c2d12', + color: '#fbbf24', + padding: '0.75rem 1rem', + borderRadius: 6, + marginBottom: '1rem', + fontSize: '0.9rem', + fontWeight: 600, + }, + safe: { + color: '#22c55e', + textAlign: 'center' as const, + padding: '2rem', + fontSize: '1.1rem', + }, + fileList: {}, + fileListHeader: { + color: '#9ca3af', + fontSize: '0.85rem', + marginBottom: '0.75rem', + }, + fileCard: { + background: '#1f2028', + borderRadius: 8, + padding: '0.75rem 1rem', + border: '1px solid #2e303a', + marginBottom: '0.5rem', + }, + fileHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '0.35rem', + }, + riskBadgeContainer: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + flex: 1, + }, + riskBar: { + height: 6, + borderRadius: 3, + maxWidth: 100, + }, + riskScore: { fontSize: '0.8rem', fontWeight: 700 }, + chainLength: { + fontSize: '0.75rem', + color: '#6b7280', + fontFamily: 'monospace', + }, + filePath: { + color: '#a5b4fc', + fontFamily: 'monospace', + fontSize: '0.85rem', + marginBottom: '0.25rem', + }, + reason: { color: '#9ca3af', fontSize: '0.8rem' }, +}; diff --git a/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs b/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs new file mode 100644 index 0000000..1583a41 --- /dev/null +++ b/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs @@ -0,0 +1,18 @@ +namespace DevBrain.Api.Endpoints; + +using DevBrain.Storage; + +public static class BlastRadiusEndpoints +{ + public static void MapBlastRadiusEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/blast-radius"); + + group.MapGet("/{*path}", async (string path, BlastRadiusCalculator calculator, int? hops) => + { + var cappedHops = Math.Min(hops ?? 3, 5); + var result = await calculator.Calculate(path, cappedHops); + return Results.Ok(result); + }); + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 6cb5e0a..998331a 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -113,7 +113,9 @@ builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(sessionStore); -builder.Services.AddSingleton(new DecisionChainBuilder(graphStore, observationStore)); +var chainBuilder = new DecisionChainBuilder(graphStore, observationStore); +builder.Services.AddSingleton(chainBuilder); +builder.Services.AddSingleton(new BlastRadiusCalculator(graphStore, deadEndStore, chainBuilder)); builder.Services.AddSingleton(llmService); builder.Services.AddSingleton(llmService); // concrete type for ResetDailyCounter builder.Services.AddSingleton(eventBus); @@ -157,6 +159,7 @@ app.MapAlertEndpoints(); app.MapSessionEndpoints(); app.MapReplayEndpoints(); +app.MapBlastRadiusEndpoints(); app.MapContextEndpoints(); app.MapDatabaseEndpoints(); app.MapSetupEndpoints(); diff --git a/src/DevBrain.Cli/Commands/BlastCommand.cs b/src/DevBrain.Cli/Commands/BlastCommand.cs new file mode 100644 index 0000000..7389b82 --- /dev/null +++ b/src/DevBrain.Cli/Commands/BlastCommand.cs @@ -0,0 +1,96 @@ +using System.CommandLine; +using System.Text.Json; +using DevBrain.Cli.Output; + +namespace DevBrain.Cli.Commands; + +public class BlastCommand : Command +{ + private readonly Argument _pathArg = new("path") + { + Description = "File path to analyze blast radius for" + }; + + public BlastCommand() : base("blast", "Show blast radius for a file") + { + Add(_pathArg); + SetAction(Execute); + } + + private async Task Execute(ParseResult pr) + { + var path = pr.GetValue(_pathArg)!; + var client = new DevBrainHttpClient(); + + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + + try + { + var json = await client.GetJson($"/api/v1/blast-radius/{Uri.EscapeDataString(path)}"); + + var sourceFile = json.GetPropertyOrDefault("sourceFile", path); + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($" Blast Radius: {sourceFile}"); + Console.ResetColor(); + Console.WriteLine(); + + // Dead ends at risk + if (json.TryGetProperty("deadEndsAtRisk", out var deadEnds) && + deadEnds.ValueKind == JsonValueKind.Array && deadEnds.GetArrayLength() > 0) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" {deadEnds.GetArrayLength()} dead end(s) at risk of re-triggering"); + Console.ResetColor(); + Console.WriteLine(); + } + + // Affected files + if (json.TryGetProperty("affectedFiles", out var files) && + files.ValueKind == JsonValueKind.Array) + { + if (files.GetArrayLength() == 0) + { + ConsoleFormatter.PrintSuccess("No affected files found. Safe to change!"); + } + else + { + Console.WriteLine($" {files.GetArrayLength()} affected file(s):\n"); + + foreach (var file in files.EnumerateArray()) + { + var filePath = file.GetPropertyOrDefault("filePath", "?"); + var risk = file.TryGetProperty("riskScore", out var r) ? r.GetDouble() : 0; + var chainLen = file.TryGetProperty("chainLength", out var cl) ? cl.GetInt32() : 0; + var reason = file.GetPropertyOrDefault("reason", ""); + + var color = risk > 0.7 ? ConsoleColor.Red + : risk > 0.3 ? ConsoleColor.Yellow + : ConsoleColor.Green; + + Console.ForegroundColor = color; + Console.Write($" {risk:F2} "); + Console.ResetColor(); + Console.Write(filePath); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" (chain: {chainLen})"); + if (!string.IsNullOrEmpty(reason)) + Console.WriteLine($" {reason}"); + Console.ResetColor(); + } + } + } + + Console.WriteLine(); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to compute blast radius: {ex.Message}"); + } + } +} diff --git a/src/DevBrain.Cli/Program.cs b/src/DevBrain.Cli/Program.cs index 5377175..026e51f 100644 --- a/src/DevBrain.Cli/Program.cs +++ b/src/DevBrain.Cli/Program.cs @@ -15,6 +15,7 @@ root.Add(new AlertsCommand()); root.Add(new StoryCommand()); root.Add(new ReplayCommand()); +root.Add(new BlastCommand()); root.Add(new RelatedCommand()); root.Add(new AgentsCommand()); root.Add(new ConfigCommand()); diff --git a/src/DevBrain.Storage/BlastRadiusCalculator.cs b/src/DevBrain.Storage/BlastRadiusCalculator.cs new file mode 100644 index 0000000..befe56a --- /dev/null +++ b/src/DevBrain.Storage/BlastRadiusCalculator.cs @@ -0,0 +1,103 @@ +namespace DevBrain.Storage; + +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class BlastRadiusCalculator +{ + private readonly IGraphStore _graph; + private readonly IDeadEndStore _deadEnds; + private readonly DecisionChainBuilder _chainBuilder; + + public BlastRadiusCalculator(IGraphStore graph, IDeadEndStore deadEnds, DecisionChainBuilder chainBuilder) + { + _graph = graph; + _deadEnds = deadEnds; + _chainBuilder = chainBuilder; + } + + public async Task Calculate(string filePath, int maxHops = 3) + { + // Step 1: Find decisions connected to this file + var related = await _graph.GetRelatedToFile(filePath); + var seedDecisions = related + .Where(n => n.Type is "Decision" or "Bug") + .ToList(); + + if (seedDecisions.Count == 0) + return new BlastRadius { SourceFile = filePath }; + + // Step 2: Traverse causal edges outward from those decisions + var allNodes = await _chainBuilder.TraverseCausalGraph(seedDecisions, maxHops); + + // Step 3: For each downstream decision, find connected File nodes + var affectedFiles = new Dictionary(); + var sourceFileNorm = filePath.ToLowerInvariant(); + + foreach (var node in allNodes.Values) + { + var fileNeighbors = await _graph.GetNeighbors(node.Id, hops: 1, edgeType: "references"); + foreach (var fileNode in fileNeighbors) + { + if (fileNode.Type != "File") continue; + if (fileNode.Name.Equals(filePath, StringComparison.OrdinalIgnoreCase)) continue; + + if (affectedFiles.ContainsKey(fileNode.Name)) continue; + + var chainLength = EstimateChainLength(seedDecisions, node, allNodes); + var deadEndsInChain = allNodes.Values.Count(n => n.Type == "Bug"); + var recency = ComputeRecencyDecay(node.CreatedAt); + var risk = ComputeRiskScore(chainLength, deadEndsInChain, recency); + + affectedFiles[fileNode.Name] = new BlastRadiusEntry + { + FilePath = fileNode.Name, + RiskScore = Math.Round(risk, 3), + ChainLength = chainLength, + Reason = $"Linked via decision: {node.Name}", + DecisionChain = [node.Id] + }; + } + } + + // Step 4: Find dead ends at risk + var deadEndsAtRisk = await _deadEnds.FindByFiles([filePath]); + var deadEndIds = deadEndsAtRisk.Select(d => d.Id).ToList(); + + // Sort affected files by risk score descending + var sortedFiles = affectedFiles.Values + .OrderByDescending(f => f.RiskScore) + .ToList(); + + return new BlastRadius + { + SourceFile = filePath, + AffectedFiles = sortedFiles, + DeadEndsAtRisk = deadEndIds + }; + } + + private static int EstimateChainLength( + List seedDecisions, GraphNode targetNode, + Dictionary allNodes) + { + // Simple estimate: if the target is a seed decision, length is 1. + // Otherwise, assume it's further away. + if (seedDecisions.Any(s => s.Id == targetNode.Id)) + return 1; + + return Math.Min(allNodes.Count, 5); + } + + private static double ComputeRecencyDecay(DateTime createdAt) + { + var daysSince = (DateTime.UtcNow - createdAt).TotalDays; + return Math.Max(0.1, 1.0 - (daysSince / 180.0)); + } + + internal static double ComputeRiskScore(int chainLength, int deadEndsInChain, double recencyDecay) + { + var deadEndMultiplier = 1.0 + (0.5 * deadEndsInChain); + return (1.0 / Math.Max(1, chainLength)) * deadEndMultiplier * recencyDecay; + } +} diff --git a/src/DevBrain.Storage/DevBrain.Storage.csproj b/src/DevBrain.Storage/DevBrain.Storage.csproj index 00e80bb..6735712 100644 --- a/src/DevBrain.Storage/DevBrain.Storage.csproj +++ b/src/DevBrain.Storage/DevBrain.Storage.csproj @@ -14,4 +14,8 @@ enable + + + + diff --git a/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs b/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs new file mode 100644 index 0000000..3cd0035 --- /dev/null +++ b/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs @@ -0,0 +1,144 @@ +using DevBrain.Core.Enums; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class BlastRadiusCalculatorTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private SqliteDeadEndStore _deadEndStore = null!; + private BlastRadiusCalculator _calculator = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _deadEndStore = new SqliteDeadEndStore(_connection); + var chainBuilder = new DecisionChainBuilder(_graphStore, _obsStore); + _calculator = new BlastRadiusCalculator(_graphStore, _deadEndStore, chainBuilder); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task Calculate_FindsAffectedFiles() + { + // Source file -> decision -> references another file + var sourceFile = await _graphStore.AddNode("File", "src/Auth.cs"); + var decision = await _graphStore.AddNode("Decision", "Add JWT auth"); + var affectedFile = await _graphStore.AddNode("File", "src/Config.cs"); + + await _graphStore.AddEdge(decision.Id, sourceFile.Id, "references"); + await _graphStore.AddEdge(decision.Id, affectedFile.Id, "references"); + + var result = await _calculator.Calculate("src/Auth.cs"); + + Assert.Equal("src/Auth.cs", result.SourceFile); + Assert.Single(result.AffectedFiles); + Assert.Equal("src/Config.cs", result.AffectedFiles[0].FilePath); + Assert.True(result.AffectedFiles[0].RiskScore > 0); + } + + [Fact] + public async Task Calculate_ExcludesSourceFileFromAffected() + { + var sourceFile = await _graphStore.AddNode("File", "src/App.cs"); + var decision = await _graphStore.AddNode("Decision", "Refactor"); + + await _graphStore.AddEdge(decision.Id, sourceFile.Id, "references"); + + var result = await _calculator.Calculate("src/App.cs"); + + Assert.Empty(result.AffectedFiles); + } + + [Fact] + public async Task Calculate_IncludesDeadEndsAtRisk() + { + var sourceFile = await _graphStore.AddNode("File", "src/Search.cs"); + var decision = await _graphStore.AddNode("Decision", "Use FTS"); + + await _graphStore.AddEdge(decision.Id, sourceFile.Id, "references"); + + await _deadEndStore.Add(new DeadEnd + { + Id = "de-1", Project = "proj", + Description = "FTS tokenizer issue", + Approach = "Default tokenizer", Reason = "No CJK support", + FilesInvolved = ["src/Search.cs"], + DetectedAt = DateTime.UtcNow.AddDays(-5) + }); + + var result = await _calculator.Calculate("src/Search.cs"); + + Assert.Single(result.DeadEndsAtRisk); + Assert.Equal("de-1", result.DeadEndsAtRisk[0]); + } + + [Fact] + public async Task Calculate_ReturnsEmptyForUnknownFile() + { + var result = await _calculator.Calculate("src/Unknown.cs"); + + Assert.Equal("src/Unknown.cs", result.SourceFile); + Assert.Empty(result.AffectedFiles); + Assert.Empty(result.DeadEndsAtRisk); + } + + [Fact] + public async Task Calculate_FollsCausalChainToFindDistantFiles() + { + // src/A.cs -> dec1 --caused_by--> dec2 -> src/B.cs + var fileA = await _graphStore.AddNode("File", "src/A.cs"); + var fileB = await _graphStore.AddNode("File", "src/B.cs"); + var dec1 = await _graphStore.AddNode("Decision", "Decision about A"); + var dec2 = await _graphStore.AddNode("Decision", "Decision about B"); + + await _graphStore.AddEdge(dec1.Id, fileA.Id, "references"); + await _graphStore.AddEdge(dec2.Id, fileB.Id, "references"); + await _graphStore.AddEdge(dec2.Id, dec1.Id, "caused_by"); + + var result = await _calculator.Calculate("src/A.cs"); + + Assert.Single(result.AffectedFiles); + Assert.Equal("src/B.cs", result.AffectedFiles[0].FilePath); + } + + [Fact] + public void ComputeRiskScore_ShortChainHigherRisk() + { + var shortChain = BlastRadiusCalculator.ComputeRiskScore(1, 0, 1.0); + var longChain = BlastRadiusCalculator.ComputeRiskScore(5, 0, 1.0); + + Assert.True(shortChain > longChain); + } + + [Fact] + public void ComputeRiskScore_DeadEndsAmplifyRisk() + { + var noDeadEnds = BlastRadiusCalculator.ComputeRiskScore(2, 0, 1.0); + var withDeadEnds = BlastRadiusCalculator.ComputeRiskScore(2, 3, 1.0); + + Assert.True(withDeadEnds > noDeadEnds); + } + + [Fact] + public void ComputeRiskScore_RecencyDecayReducesRisk() + { + var recent = BlastRadiusCalculator.ComputeRiskScore(2, 0, 1.0); + var stale = BlastRadiusCalculator.ComputeRiskScore(2, 0, 0.2); + + Assert.True(recent > stale); + } +} From 507b339a6767a83ec6dbbcc7e80a4e285a4b8a89 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:41:16 -0700 Subject: [PATCH 19/36] fix: address Blast Radius review findings High: - Clamp risk score to [0, 1] with Math.Min(1.0, raw) - Replace EstimateChainLength with real depth tracking via TraverseCausalGraphWithDepth (BFS with per-node depth) - deadEndsInChain now per-node (Bug type = 1, else 0) instead of global count inflating all scores equally Medium: - Remove dead sourceFileNorm variable Low: - Clamp hops parameter: Math.Clamp(hops ?? 3, 1, 5) - Rename DecisionChain field to LinkedDecisionId (was always 1 element) - Add --hops option to CLI BlastCommand - Fix test name typo: Folls -> Follows - 2 new tests: score clamped to 1.0, closer files get higher risk All 97 tests passing, dashboard builds clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/api/client.ts | 2 +- .../Endpoints/BlastRadiusEndpoints.cs | 2 +- src/DevBrain.Cli/Commands/BlastCommand.cs | 11 ++++- src/DevBrain.Core/Models/BlastRadius.cs | 2 +- src/DevBrain.Storage/BlastRadiusCalculator.cs | 35 +++++----------- src/DevBrain.Storage/DecisionChainBuilder.cs | 40 +++++++++++++++++++ .../BlastRadiusCalculatorTests.cs | 38 +++++++++++++++++- 7 files changed, 101 insertions(+), 29 deletions(-) diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 823d2bb..c0d0496 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -203,7 +203,7 @@ export interface BlastRadiusEntry { riskScore: number; chainLength: number; reason: string; - decisionChain: string[]; + linkedDecisionId?: string; } export interface BlastRadius { diff --git a/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs b/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs index 1583a41..90e4332 100644 --- a/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs +++ b/src/DevBrain.Api/Endpoints/BlastRadiusEndpoints.cs @@ -10,7 +10,7 @@ public static void MapBlastRadiusEndpoints(this WebApplication app) group.MapGet("/{*path}", async (string path, BlastRadiusCalculator calculator, int? hops) => { - var cappedHops = Math.Min(hops ?? 3, 5); + var cappedHops = Math.Clamp(hops ?? 3, 1, 5); var result = await calculator.Calculate(path, cappedHops); return Results.Ok(result); }); diff --git a/src/DevBrain.Cli/Commands/BlastCommand.cs b/src/DevBrain.Cli/Commands/BlastCommand.cs index 7389b82..f3e335a 100644 --- a/src/DevBrain.Cli/Commands/BlastCommand.cs +++ b/src/DevBrain.Cli/Commands/BlastCommand.cs @@ -11,15 +11,22 @@ public class BlastCommand : Command Description = "File path to analyze blast radius for" }; + private readonly Option _hopsOption = new("--hops") + { + Description = "Traversal depth (1-5, default 3)" + }; + public BlastCommand() : base("blast", "Show blast radius for a file") { Add(_pathArg); + Add(_hopsOption); SetAction(Execute); } private async Task Execute(ParseResult pr) { var path = pr.GetValue(_pathArg)!; + var hops = pr.GetValue(_hopsOption); var client = new DevBrainHttpClient(); if (!await client.IsHealthy()) @@ -30,7 +37,9 @@ private async Task Execute(ParseResult pr) try { - var json = await client.GetJson($"/api/v1/blast-radius/{Uri.EscapeDataString(path)}"); + var url = $"/api/v1/blast-radius/{Uri.EscapeDataString(path)}"; + if (hops.HasValue) url += $"?hops={hops.Value}"; + var json = await client.GetJson(url); var sourceFile = json.GetPropertyOrDefault("sourceFile", path); diff --git a/src/DevBrain.Core/Models/BlastRadius.cs b/src/DevBrain.Core/Models/BlastRadius.cs index e6362aa..81a1358 100644 --- a/src/DevBrain.Core/Models/BlastRadius.cs +++ b/src/DevBrain.Core/Models/BlastRadius.cs @@ -15,5 +15,5 @@ public record BlastRadiusEntry public required double RiskScore { get; init; } public required int ChainLength { get; init; } public required string Reason { get; init; } - public IReadOnlyList DecisionChain { get; init; } = []; + public string? LinkedDecisionId { get; init; } } diff --git a/src/DevBrain.Storage/BlastRadiusCalculator.cs b/src/DevBrain.Storage/BlastRadiusCalculator.cs index befe56a..f1ce202 100644 --- a/src/DevBrain.Storage/BlastRadiusCalculator.cs +++ b/src/DevBrain.Storage/BlastRadiusCalculator.cs @@ -27,27 +27,26 @@ public async Task Calculate(string filePath, int maxHops = 3) if (seedDecisions.Count == 0) return new BlastRadius { SourceFile = filePath }; - // Step 2: Traverse causal edges outward from those decisions - var allNodes = await _chainBuilder.TraverseCausalGraph(seedDecisions, maxHops); + // Step 2: Traverse causal edges with depth tracking + var nodesWithDepth = await _chainBuilder.TraverseCausalGraphWithDepth(seedDecisions, maxHops); // Step 3: For each downstream decision, find connected File nodes - var affectedFiles = new Dictionary(); - var sourceFileNorm = filePath.ToLowerInvariant(); + var affectedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var node in allNodes.Values) + foreach (var (nodeId, (node, depth)) in nodesWithDepth) { var fileNeighbors = await _graph.GetNeighbors(node.Id, hops: 1, edgeType: "references"); foreach (var fileNode in fileNeighbors) { if (fileNode.Type != "File") continue; if (fileNode.Name.Equals(filePath, StringComparison.OrdinalIgnoreCase)) continue; - if (affectedFiles.ContainsKey(fileNode.Name)) continue; - var chainLength = EstimateChainLength(seedDecisions, node, allNodes); - var deadEndsInChain = allNodes.Values.Count(n => n.Type == "Bug"); + // Count dead ends relevant to this specific node's chain path + var deadEndsForNode = node.Type == "Bug" ? 1 : 0; + var chainLength = depth + 1; // depth 0 = direct connection = chain length 1 var recency = ComputeRecencyDecay(node.CreatedAt); - var risk = ComputeRiskScore(chainLength, deadEndsInChain, recency); + var risk = ComputeRiskScore(chainLength, deadEndsForNode, recency); affectedFiles[fileNode.Name] = new BlastRadiusEntry { @@ -55,7 +54,7 @@ public async Task Calculate(string filePath, int maxHops = 3) RiskScore = Math.Round(risk, 3), ChainLength = chainLength, Reason = $"Linked via decision: {node.Name}", - DecisionChain = [node.Id] + LinkedDecisionId = node.Id }; } } @@ -64,7 +63,6 @@ public async Task Calculate(string filePath, int maxHops = 3) var deadEndsAtRisk = await _deadEnds.FindByFiles([filePath]); var deadEndIds = deadEndsAtRisk.Select(d => d.Id).ToList(); - // Sort affected files by risk score descending var sortedFiles = affectedFiles.Values .OrderByDescending(f => f.RiskScore) .ToList(); @@ -77,18 +75,6 @@ public async Task Calculate(string filePath, int maxHops = 3) }; } - private static int EstimateChainLength( - List seedDecisions, GraphNode targetNode, - Dictionary allNodes) - { - // Simple estimate: if the target is a seed decision, length is 1. - // Otherwise, assume it's further away. - if (seedDecisions.Any(s => s.Id == targetNode.Id)) - return 1; - - return Math.Min(allNodes.Count, 5); - } - private static double ComputeRecencyDecay(DateTime createdAt) { var daysSince = (DateTime.UtcNow - createdAt).TotalDays; @@ -98,6 +84,7 @@ private static double ComputeRecencyDecay(DateTime createdAt) internal static double ComputeRiskScore(int chainLength, int deadEndsInChain, double recencyDecay) { var deadEndMultiplier = 1.0 + (0.5 * deadEndsInChain); - return (1.0 / Math.Max(1, chainLength)) * deadEndMultiplier * recencyDecay; + var raw = (1.0 / Math.Max(1, chainLength)) * deadEndMultiplier * recencyDecay; + return Math.Min(1.0, raw); } } diff --git a/src/DevBrain.Storage/DecisionChainBuilder.cs b/src/DevBrain.Storage/DecisionChainBuilder.cs index 5cbab04..1d38884 100644 --- a/src/DevBrain.Storage/DecisionChainBuilder.cs +++ b/src/DevBrain.Storage/DecisionChainBuilder.cs @@ -46,6 +46,46 @@ public async Task> TraverseCausalGraph( return allNodes; } + /// + /// Traverse causal edges with depth tracking per node. Used by BlastRadiusCalculator + /// to compute accurate chain lengths for risk scoring. + /// + public async Task> TraverseCausalGraphWithDepth( + IEnumerable seedNodes, int maxHops, + IReadOnlyList? edgeTypes = null, + HashSet? nodeTypeFilter = null) + { + var types = edgeTypes ?? CausalEdgeTypes; + var filter = nodeTypeFilter ?? DecisionNodeTypes; + var result = new Dictionary(); + + // BFS with depth tracking + var seedList = seedNodes.ToList(); + var queue = new Queue<(GraphNode Node, int Depth)>(); + + foreach (var seed in seedList) + { + if (filter.Contains(seed.Type) && result.TryAdd(seed.Id, (seed, 0))) + queue.Enqueue((seed, 0)); + } + + while (queue.Count > 0) + { + var (current, depth) = queue.Dequeue(); + if (depth >= maxHops) continue; + + var neighbors = await _graph.GetNeighbors(current.Id, hops: 1, types); + foreach (var neighbor in neighbors) + { + if (!filter.Contains(neighbor.Type)) continue; + if (result.TryAdd(neighbor.Id, (neighbor, depth + 1))) + queue.Enqueue((neighbor, depth + 1)); + } + } + + return result; + } + public async Task BuildForFile(string filePath, int maxHops = 3) { var related = await _graph.GetRelatedToFile(filePath); diff --git a/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs b/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs index 3cd0035..988c193 100644 --- a/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs +++ b/tests/DevBrain.Storage.Tests/BlastRadiusCalculatorTests.cs @@ -97,7 +97,7 @@ public async Task Calculate_ReturnsEmptyForUnknownFile() } [Fact] - public async Task Calculate_FollsCausalChainToFindDistantFiles() + public async Task Calculate_FollowsCausalChainToFindDistantFiles() { // src/A.cs -> dec1 --caused_by--> dec2 -> src/B.cs var fileA = await _graphStore.AddNode("File", "src/A.cs"); @@ -115,6 +115,42 @@ public async Task Calculate_FollsCausalChainToFindDistantFiles() Assert.Equal("src/B.cs", result.AffectedFiles[0].FilePath); } + [Fact] + public async Task Calculate_CloserFilesGetHigherRiskThanDistant() + { + // src/Root.cs -> dec1 --caused_by--> dec2 --caused_by--> dec3 + // dec1 references src/Close.cs (1 hop), dec3 references src/Far.cs (2 hops) + var rootFile = await _graphStore.AddNode("File", "src/Root.cs"); + var closeFile = await _graphStore.AddNode("File", "src/Close.cs"); + var farFile = await _graphStore.AddNode("File", "src/Far.cs"); + var dec1 = await _graphStore.AddNode("Decision", "Root decision"); + var dec2 = await _graphStore.AddNode("Decision", "Middle decision"); + var dec3 = await _graphStore.AddNode("Decision", "Far decision"); + + await _graphStore.AddEdge(dec1.Id, rootFile.Id, "references"); + await _graphStore.AddEdge(dec1.Id, closeFile.Id, "references"); + await _graphStore.AddEdge(dec3.Id, farFile.Id, "references"); + await _graphStore.AddEdge(dec2.Id, dec1.Id, "caused_by"); + await _graphStore.AddEdge(dec3.Id, dec2.Id, "caused_by"); + + var result = await _calculator.Calculate("src/Root.cs"); + + Assert.Equal(2, result.AffectedFiles.Count); + + var closeEntry = result.AffectedFiles.First(f => f.FilePath == "src/Close.cs"); + var farEntry = result.AffectedFiles.First(f => f.FilePath == "src/Far.cs"); + Assert.True(closeEntry.RiskScore >= farEntry.RiskScore, + $"Close ({closeEntry.RiskScore}) should be >= Far ({farEntry.RiskScore})"); + } + + [Fact] + public void ComputeRiskScore_ClampedToOneMax() + { + // With deadEnds and short chain, raw score would exceed 1.0 + var score = BlastRadiusCalculator.ComputeRiskScore(1, 5, 1.0); + Assert.True(score <= 1.0, $"Score {score} should be <= 1.0"); + } + [Fact] public void ComputeRiskScore_ShortChainHigherRisk() { From dc4ef2b4fd733404d808766aa60c3e36fb1adc0e Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:01:59 -0700 Subject: [PATCH 20/36] =?UTF-8?q?feat:=20add=20Developer=20Growth=20Tracke?= =?UTF-8?q?r=20=E2=80=94=20store,=20agent,=20API,=20CLI,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IGrowthStore with metrics, milestones, reports CRUD + Clear - SqliteGrowthStore persists to developer_metrics, milestones, growth_reports - GrowthAgent (weekly cron): computes 8 metric dimensions (debugging_speed, dead_end_rate, exploration_breadth, decision_velocity, retry_rate, tool_repertoire, problem_complexity, code_quality) - Milestone detection: first-project, 20%+ improvement, composite complexity-up-quality-holding insight - LLM narrative via Prompts.GrowthNarrative (PreferLocal) - GrowthEndpoints: GET /growth, /growth/history, /growth/milestones - GrowthCommand CLI: devbrain growth, growth milestones, growth reset - Growth dashboard page with narrative card, metrics grid, milestone timeline - 5 store tests + 7 agent tests (metrics computation, milestone detection) - Wire IGrowthStore, GrowthAgent in Program.cs Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/src/App.tsx | 2 + dashboard/src/api/client.ts | 38 ++ dashboard/src/components/Navigation.tsx | 1 + dashboard/src/pages/Growth.tsx | 160 +++++++++ src/DevBrain.Agents/GrowthAgent.cs | 338 ++++++++++++++++++ src/DevBrain.Api/Endpoints/GrowthEndpoints.cs | 37 ++ src/DevBrain.Api/Program.cs | 6 +- src/DevBrain.Cli/Commands/GrowthCommand.cs | 109 ++++++ src/DevBrain.Cli/Program.cs | 1 + src/DevBrain.Core/Interfaces/IGrowthStore.cs | 19 + src/DevBrain.Storage/SqliteGrowthStore.cs | 190 ++++++++++ .../DevBrain.Agents.Tests/GrowthAgentTests.cs | 182 ++++++++++ .../SqliteGrowthStoreTests.cs | 121 +++++++ 13 files changed, 1203 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/pages/Growth.tsx create mode 100644 src/DevBrain.Agents/GrowthAgent.cs create mode 100644 src/DevBrain.Api/Endpoints/GrowthEndpoints.cs create mode 100644 src/DevBrain.Cli/Commands/GrowthCommand.cs create mode 100644 src/DevBrain.Core/Interfaces/IGrowthStore.cs create mode 100644 src/DevBrain.Storage/SqliteGrowthStore.cs create mode 100644 tests/DevBrain.Agents.Tests/GrowthAgentTests.cs create mode 100644 tests/DevBrain.Storage.Tests/SqliteGrowthStoreTests.cs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 5b4ed52..294da4f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -14,6 +14,7 @@ import Alerts from './pages/Alerts'; import Sessions from './pages/Sessions'; import Replay from './pages/Replay'; import BlastRadius from './pages/BlastRadius'; +import Growth from './pages/Growth'; export default function App() { return ( @@ -35,6 +36,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index c0d0496..74bfe78 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -245,6 +245,35 @@ export interface SessionSummary { createdAt: string; } +// Growth Tracker models +export interface DeveloperMetric { + id: string; + dimension: string; + value: number; + periodStart: string; + periodEnd: string; + createdAt: string; +} + +export interface GrowthMilestone { + id: string; + type: string; + description: string; + achievedAt: string; + observationId?: string; + createdAt: string; +} + +export interface GrowthReport { + id: string; + periodStart: string; + periodEnd: string; + metrics: DeveloperMetric[]; + milestones: GrowthMilestone[]; + narrative?: string; + generatedAt: string; +} + // Database explorer types export interface DbTableInfo { name: string; @@ -414,6 +443,15 @@ export const api = { replayDecision: (nodeId: string) => fetchJson(`/replay/decision/${encodeURIComponent(nodeId)}`), + // Growth + growth: () => fetchJson('/growth'), + + growthHistory: (dimension: string, weeks = 12) => + fetchJson(`/growth/history?dimension=${encodeURIComponent(dimension)}&weeks=${weeks}`), + + growthMilestones: (limit = 50) => + fetchJson(`/growth/milestones?limit=${limit}`), + // Sessions sessions: (limit = 50) => fetchJson(`/sessions?limit=${limit}`), diff --git a/dashboard/src/components/Navigation.tsx b/dashboard/src/components/Navigation.tsx index 8a40732..c2587e2 100644 --- a/dashboard/src/components/Navigation.tsx +++ b/dashboard/src/components/Navigation.tsx @@ -8,6 +8,7 @@ const links = [ { to: '/sessions', label: 'Sessions' }, { to: '/replay', label: 'Replay' }, { to: '/blast-radius', label: 'Blast Radius' }, + { to: '/growth', label: 'Growth' }, { to: '/threads', label: 'Threads' }, { to: '/search', label: 'Search' }, { to: '/settings', label: 'Settings' }, diff --git a/dashboard/src/pages/Growth.tsx b/dashboard/src/pages/Growth.tsx new file mode 100644 index 0000000..a4c6de5 --- /dev/null +++ b/dashboard/src/pages/Growth.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import { api, type GrowthReport, type GrowthMilestone } from '../api/client'; + +const milestoneColors: Record = { + First: '#3b82f6', + Streak: '#eab308', + Improvement: '#22c55e', +}; + +export default function Growth() { + const [report, setReport] = useState(null); + const [milestones, setMilestones] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + Promise.all([ + api.growth().catch(() => null), + api.growthMilestones().catch(() => []), + ]) + .then(([reportData, milestonesData]) => { + if (reportData && 'id' in reportData) setReport(reportData); + setMilestones(milestonesData as GrowthMilestone[]); + setLoading(false); + }) + .catch((e) => { + setError(String(e)); + setLoading(false); + }); + }, []); + + if (error) return
Error: {error}
; + if (loading) return
Loading growth data...
; + + return ( +
+

Developer Growth

+ + {/* Narrative */} + {report?.narrative && ( +
+
This Week
+
{report.narrative}
+
+ )} + + {/* Metrics */} + {report?.metrics && report.metrics.length > 0 && ( +
+ {report.metrics.map((m) => ( +
+
{m.dimension.replace(/_/g, ' ')}
+
{m.value.toFixed(2)}
+
+ ))} +
+ )} + + {!report && ( +

+ No growth reports yet. The growth agent runs weekly (Monday 8 AM). +

+ )} + + {/* Milestones */} +

Milestones

+ {milestones.length === 0 ? ( +

No milestones yet.

+ ) : ( +
+ {milestones.map((m) => ( +
+ + {m.type} + + {m.description} + + {new Date(m.achievedAt).toLocaleDateString()} + +
+ ))} +
+ )} +
+ ); +} + +const styles: Record = { + container: { padding: '1.5rem', maxWidth: 800, margin: '0 auto' }, + narrativeCard: { + background: '#1f2028', + borderRadius: 8, + padding: '1.25rem', + border: '1px solid #22c55e33', + marginBottom: '1.5rem', + }, + narrativeLabel: { + fontSize: '0.75rem', + color: '#22c55e', + fontWeight: 700, + textTransform: 'uppercase' as const, + marginBottom: '0.5rem', + }, + narrative: { color: '#e5e7eb', fontSize: '0.95rem', lineHeight: 1.5 }, + metricsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', + gap: '0.75rem', + marginBottom: '2rem', + }, + metricCard: { + background: '#1f2028', + borderRadius: 8, + padding: '1rem', + border: '1px solid #2e303a', + textAlign: 'center' as const, + }, + metricDimension: { + fontSize: '0.75rem', + color: '#9ca3af', + textTransform: 'capitalize' as const, + marginBottom: '0.5rem', + }, + metricValue: { + fontSize: '1.5rem', + fontWeight: 700, + color: '#f3f4f6', + fontFamily: 'monospace', + }, + sectionTitle: { fontSize: '1.1rem', color: '#d1d5db', marginBottom: '1rem' }, + milestoneList: { display: 'flex', flexDirection: 'column', gap: '0.5rem' }, + milestoneCard: { + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + padding: '0.5rem 0.75rem', + background: '#1f2028', + borderRadius: 6, + border: '1px solid #2e303a', + }, + milestoneBadge: { + fontSize: '0.65rem', + color: '#fff', + padding: '2px 8px', + borderRadius: 4, + fontWeight: 700, + textTransform: 'uppercase' as const, + flexShrink: 0, + }, + milestoneDesc: { flex: 1, color: '#e5e7eb', fontSize: '0.85rem' }, + milestoneDate: { fontSize: '0.75rem', color: '#6b7280', flexShrink: 0 }, + loading: { padding: '2rem', textAlign: 'center' as const, color: '#9ca3af' }, + error: { padding: '2rem', textAlign: 'center' as const, color: '#ef4444' }, + empty: { color: '#6b7280', textAlign: 'center' as const, padding: '1rem' }, +}; diff --git a/src/DevBrain.Agents/GrowthAgent.cs b/src/DevBrain.Agents/GrowthAgent.cs new file mode 100644 index 0000000..fa5ce01 --- /dev/null +++ b/src/DevBrain.Agents/GrowthAgent.cs @@ -0,0 +1,338 @@ +namespace DevBrain.Agents; + +using DevBrain.Core; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; + +public class GrowthAgent : IIntelligenceAgent +{ + private readonly IGrowthStore _growthStore; + + public GrowthAgent(IGrowthStore growthStore) + { + _growthStore = growthStore; + } + + public string Name => "growth"; + + public AgentSchedule Schedule => new AgentSchedule.Cron("0 8 * * 1"); + + public Priority Priority => Priority.Low; + + public async Task> Run(AgentContext ctx, CancellationToken ct) + { + var outputs = new List(); + var now = DateTime.UtcNow; + var periodStart = now.AddDays(-7); + var periodEnd = now; + + // Get all observations for the past week + var weekObs = await ctx.Observations.Query(new ObservationFilter + { + After = periodStart, + Limit = 1000 + }); + + if (weekObs.Count == 0) + return outputs; + + // Compute metrics + var metrics = new List(); + + // 1. Debugging speed: avg minutes from Error to no-more-errors in thread + var debuggingSpeed = ComputeDebuggingSpeed(weekObs); + metrics.Add(CreateMetric("debugging_speed", debuggingSpeed, periodStart, periodEnd)); + + // 2. Dead-end rate + var deadEnds = await ctx.DeadEnds.Query(new DeadEndFilter { After = periodStart }); + var sessionIds = weekObs.Select(o => o.SessionId).Distinct().Count(); + var deadEndRate = sessionIds > 0 ? (double)deadEnds.Count / sessionIds : 0; + metrics.Add(CreateMetric("dead_end_rate", Math.Round(deadEndRate, 2), periodStart, periodEnd)); + + // 3. Exploration breadth: unique files per session + var filesPerSession = weekObs + .GroupBy(o => o.SessionId) + .Select(g => g.SelectMany(o => o.FilesInvolved).Distinct(StringComparer.OrdinalIgnoreCase).Count()) + .DefaultIfEmpty(0) + .Average(); + metrics.Add(CreateMetric("exploration_breadth", Math.Round(filesPerSession, 1), periodStart, periodEnd)); + + // 4. Decision velocity: avg minutes from first FileChange to first Decision per thread + var decisionVelocity = ComputeDecisionVelocity(weekObs); + metrics.Add(CreateMetric("decision_velocity", Math.Round(decisionVelocity, 1), periodStart, periodEnd)); + + // 5. Retry rate: sessions with 3+ edits to same file + var retryRate = ComputeRetryRate(weekObs); + metrics.Add(CreateMetric("retry_rate", Math.Round(retryRate, 2), periodStart, periodEnd)); + + // 6. Tool repertoire: distinct ToolCall observations + var toolCount = weekObs + .Where(o => o.EventType == EventType.ToolCall) + .SelectMany(o => o.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + metrics.Add(CreateMetric("tool_repertoire", toolCount, periodStart, periodEnd)); + + // 7. Problem complexity (heuristic only — Llama enhancement deferred) + var complexity = ComputeHeuristicComplexity(weekObs); + metrics.Add(CreateMetric("problem_complexity", Math.Round(complexity, 2), periodStart, periodEnd)); + + // 8. Code quality (heuristic — all errors count equally without Llama) + var errorCount = weekObs.Count(o => o.EventType == EventType.Error); + var quality = weekObs.Count > 0 ? 1.0 - ((double)errorCount / weekObs.Count) : 1.0; + metrics.Add(CreateMetric("code_quality", Math.Round(quality, 3), periodStart, periodEnd)); + + // Persist metrics + foreach (var metric in metrics) + await _growthStore.AddMetric(metric); + + // Detect milestones + var milestones = await DetectMilestones(ctx, weekObs, metrics, periodStart); + foreach (var milestone in milestones) + { + await _growthStore.AddMilestone(milestone); + outputs.Add(new AgentOutput(AgentOutputType.MilestoneAchieved, milestone.Description)); + } + + // Generate LLM narrative + string? narrative = null; + try + { + var prompt = BuildNarrativePrompt(metrics, milestones); + var task = new LlmTask + { + AgentName = Name, + Priority = Priority.Low, + Type = LlmTaskType.Synthesis, + Prompt = prompt, + Preference = LlmPreference.PreferLocal + }; + var result = await ctx.Llm.Submit(task, ct); + if (result.Success && !string.IsNullOrEmpty(result.Content)) + narrative = result.Content.Trim(); + } + catch (OperationCanceledException) { throw; } + catch { /* LLM failure is non-fatal for growth reports */ } + + // Persist report + var report = new GrowthReport + { + Id = Guid.NewGuid().ToString(), + PeriodStart = periodStart, + PeriodEnd = periodEnd, + Metrics = metrics, + Milestones = milestones, + Narrative = narrative + }; + await _growthStore.AddReport(report); + + outputs.Add(new AgentOutput(AgentOutputType.GrowthReportGenerated, + $"Growth report generated: {metrics.Count} metrics, {milestones.Count} milestones")); + + return outputs; + } + + internal static double ComputeDebuggingSpeed(IReadOnlyList observations) + { + var threads = observations + .Where(o => !string.IsNullOrEmpty(o.ThreadId)) + .GroupBy(o => o.ThreadId!); + + var durations = new List(); + foreach (var thread in threads) + { + var sorted = thread.OrderBy(o => o.Timestamp).ToList(); + var firstError = sorted.FirstOrDefault(o => o.EventType == EventType.Error); + if (firstError is null) continue; + + var lastError = sorted.LastOrDefault(o => o.EventType == EventType.Error); + if (lastError is null) continue; + + var hasSubsequentWork = sorted + .Any(o => o.Timestamp > lastError.Timestamp && o.EventType != EventType.Error); + + if (hasSubsequentWork) + durations.Add((lastError.Timestamp - firstError.Timestamp).TotalMinutes); + } + + return durations.Count > 0 ? durations.Average() : 0; + } + + internal static double ComputeDecisionVelocity(IReadOnlyList observations) + { + var threads = observations + .Where(o => !string.IsNullOrEmpty(o.ThreadId)) + .GroupBy(o => o.ThreadId!); + + var velocities = new List(); + foreach (var thread in threads) + { + var sorted = thread.OrderBy(o => o.Timestamp).ToList(); + var firstChange = sorted.FirstOrDefault(o => o.EventType == EventType.FileChange); + var firstDecision = sorted.FirstOrDefault(o => o.EventType == EventType.Decision); + + if (firstChange is not null && firstDecision is not null && firstDecision.Timestamp > firstChange.Timestamp) + velocities.Add((firstDecision.Timestamp - firstChange.Timestamp).TotalMinutes); + } + + return velocities.Count > 0 ? velocities.Average() : 0; + } + + internal static double ComputeRetryRate(IReadOnlyList observations) + { + var sessions = observations.GroupBy(o => o.SessionId); + var totalSessions = 0; + var retrySessions = 0; + + foreach (var session in sessions) + { + totalSessions++; + var hasRetry = session + .Where(o => o.EventType == EventType.FileChange) + .SelectMany(o => o.FilesInvolved) + .GroupBy(f => f, StringComparer.OrdinalIgnoreCase) + .Any(g => g.Count() >= 3); + if (hasRetry) retrySessions++; + } + + return totalSessions > 0 ? (double)retrySessions / totalSessions : 0; + } + + internal static double ComputeHeuristicComplexity(IReadOnlyList observations) + { + var threads = observations + .Where(o => !string.IsNullOrEmpty(o.ThreadId)) + .GroupBy(o => o.ThreadId!); + + var scores = new List(); + foreach (var thread in threads) + { + var sorted = thread.OrderBy(o => o.Timestamp).ToList(); + var filesInvolved = sorted.SelectMany(o => o.FilesInvolved).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + var decisions = sorted.Count(o => o.EventType == EventType.Decision); + var durationHours = sorted.Count > 1 + ? (sorted[^1].Timestamp - sorted[0].Timestamp).TotalHours + : 0; + var crossProjectRefs = sorted.Select(o => o.Project).Distinct().Count(); + + var raw = (filesInvolved * 0.3) + (decisions * 0.25) + (durationHours * 0.2) + (crossProjectRefs * 0.25); + scores.Add(Math.Clamp(raw / 4.0, 1.0, 5.0)); + } + + return scores.Count > 0 ? scores.Average() : 1.0; + } + + private async Task> DetectMilestones( + AgentContext ctx, IReadOnlyList weekObs, + IReadOnlyList currentMetrics, DateTime periodStart) + { + var milestones = new List(); + + // "First" milestones: new projects + var currentProjects = weekObs.Select(o => o.Project).Distinct().ToList(); + var historicalObs = await ctx.Observations.Query(new ObservationFilter + { + Before = periodStart, Limit = 5000 + }); + var historicalProjects = historicalObs.Select(o => o.Project).Distinct().ToHashSet(); + + foreach (var project in currentProjects) + { + if (!historicalProjects.Contains(project)) + { + milestones.Add(new GrowthMilestone + { + Id = Guid.NewGuid().ToString(), + Type = MilestoneType.First, + Description = $"First contribution to {project}", + AchievedAt = DateTime.UtcNow + }); + } + } + + // "Improvement" milestones: any metric > 20% better than 4-week average + foreach (var metric in currentMetrics) + { + var history = await _growthStore.GetMetrics(metric.Dimension, weeks: 4); + if (history.Count < 2) continue; + + var avg = history.Where(m => m.Id != metric.Id).Select(m => m.Value).DefaultIfEmpty(0).Average(); + if (avg == 0) continue; + + // For rate metrics (dead_end_rate, retry_rate), lower is better + var isLowerBetter = metric.Dimension is "dead_end_rate" or "retry_rate" or "debugging_speed"; + var improvement = isLowerBetter + ? (avg - metric.Value) / avg + : (metric.Value - avg) / avg; + + if (improvement > 0.20) + { + milestones.Add(new GrowthMilestone + { + Id = Guid.NewGuid().ToString(), + Type = MilestoneType.Improvement, + Description = $"{metric.Dimension} improved by {improvement:P0} this week", + AchievedAt = DateTime.UtcNow + }); + } + } + + // Composite: complexity up + quality holding + var complexityMetric = currentMetrics.FirstOrDefault(m => m.Dimension == "problem_complexity"); + var qualityMetric = currentMetrics.FirstOrDefault(m => m.Dimension == "code_quality"); + if (complexityMetric is not null && qualityMetric is not null) + { + var complexityHistory = await _growthStore.GetMetrics("problem_complexity", 4); + var qualityHistory = await _growthStore.GetMetrics("code_quality", 4); + + if (complexityHistory.Count >= 2 && qualityHistory.Count >= 2) + { + var complexityAvg = complexityHistory.Where(m => m.Id != complexityMetric.Id).Select(m => m.Value).Average(); + var qualityAvg = qualityHistory.Where(m => m.Id != qualityMetric.Id).Select(m => m.Value).Average(); + + var complexityUp = complexityAvg > 0 && (complexityMetric.Value - complexityAvg) / complexityAvg > 0.10; + var qualityStable = qualityAvg > 0 && Math.Abs(qualityMetric.Value - qualityAvg) / qualityAvg <= 0.05; + + if (complexityUp && qualityStable) + { + milestones.Add(new GrowthMilestone + { + Id = Guid.NewGuid().ToString(), + Type = MilestoneType.Improvement, + Description = "Complexity up with quality holding steady — you're leveling up", + AchievedAt = DateTime.UtcNow + }); + } + } + } + + return milestones; + } + + private static string BuildNarrativePrompt( + IReadOnlyList metrics, IReadOnlyList milestones) + { + var metricsStr = string.Join(", ", metrics.Select(m => $"{m.Dimension}: {m.Value}")); + var milestonesStr = milestones.Count > 0 + ? string.Join("; ", milestones.Select(m => m.Description)) + : "None this week"; + + return Prompts.Fill(Prompts.GrowthNarrative, + ("METRICS", metricsStr), + ("MILESTONES", milestonesStr), + ("TREND", "N/A"), + ("COMPLEXITY", metrics.FirstOrDefault(m => m.Dimension == "problem_complexity")?.Value.ToString() ?? "N/A"), + ("QUALITY", metrics.FirstOrDefault(m => m.Dimension == "code_quality")?.Value.ToString() ?? "N/A"), + ("ERROR_BREAKDOWN", "N/A")); + } + + private static DeveloperMetric CreateMetric(string dimension, double value, DateTime start, DateTime end) => new() + { + Id = Guid.NewGuid().ToString(), + Dimension = dimension, + Value = value, + PeriodStart = start, + PeriodEnd = end + }; +} diff --git a/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs b/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs new file mode 100644 index 0000000..cdcc30b --- /dev/null +++ b/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs @@ -0,0 +1,37 @@ +namespace DevBrain.Api.Endpoints; + +using DevBrain.Core.Interfaces; + +public static class GrowthEndpoints +{ + public static void MapGrowthEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/growth"); + + group.MapGet("/", async (IGrowthStore growthStore) => + { + var report = await growthStore.GetLatestReport(); + if (report is null) + return Results.Ok(new { status = "no_data", message = "No growth reports yet." }); + return Results.Ok(report); + }); + + group.MapGet("/history", async (IGrowthStore growthStore, string? dimension, int? weeks) => + { + if (!string.IsNullOrEmpty(dimension)) + { + var metrics = await growthStore.GetMetrics(dimension, weeks ?? 12); + return Results.Ok(metrics); + } + + var latest = await growthStore.GetLatestMetrics(); + return Results.Ok(latest); + }); + + group.MapGet("/milestones", async (IGrowthStore growthStore, int? limit) => + { + var milestones = await growthStore.GetMilestones(Math.Min(limit ?? 50, 200)); + return Results.Ok(milestones); + }); + } +} diff --git a/src/DevBrain.Api/Program.cs b/src/DevBrain.Api/Program.cs index 998331a..1437cc6 100644 --- a/src/DevBrain.Api/Program.cs +++ b/src/DevBrain.Api/Program.cs @@ -40,6 +40,7 @@ var deadEndStore = new SqliteDeadEndStore(connection); var alertStore = new SqliteAlertStore(connection); var sessionStore = new SqliteSessionStore(connection); +var growthStore = new SqliteGrowthStore(connection); var alertChannel = new DevBrain.Api.Services.AlertChannel(); // ── Vector store (placeholder) ─────────────────────────────────────────────── @@ -88,7 +89,8 @@ new CompressionAgent(), new DecisionChainAgent(), new DejaVuAgent(alertStore, alertChannel), - new StorytellerAgent(sessionStore) + new StorytellerAgent(sessionStore), + new GrowthAgent(growthStore) }; // ── ASP.NET Core host ──────────────────────────────────────────────────────── @@ -113,6 +115,7 @@ builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(alertChannel); builder.Services.AddSingleton(sessionStore); +builder.Services.AddSingleton(growthStore); var chainBuilder = new DecisionChainBuilder(graphStore, observationStore); builder.Services.AddSingleton(chainBuilder); builder.Services.AddSingleton(new BlastRadiusCalculator(graphStore, deadEndStore, chainBuilder)); @@ -160,6 +163,7 @@ app.MapSessionEndpoints(); app.MapReplayEndpoints(); app.MapBlastRadiusEndpoints(); +app.MapGrowthEndpoints(); app.MapContextEndpoints(); app.MapDatabaseEndpoints(); app.MapSetupEndpoints(); diff --git a/src/DevBrain.Cli/Commands/GrowthCommand.cs b/src/DevBrain.Cli/Commands/GrowthCommand.cs new file mode 100644 index 0000000..7dcaf6e --- /dev/null +++ b/src/DevBrain.Cli/Commands/GrowthCommand.cs @@ -0,0 +1,109 @@ +using System.CommandLine; +using System.Text.Json; +using DevBrain.Cli.Output; + +namespace DevBrain.Cli.Commands; + +public class GrowthCommand : Command +{ + public GrowthCommand() : base("growth", "Show developer growth report") + { + var milestonesCmd = new Command("milestones", "Show milestones"); + milestonesCmd.SetAction(async (pr) => + { + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) { ConsoleFormatter.PrintError("Daemon is not running."); return; } + + var json = await client.GetJson("/api/v1/growth/milestones"); + if (json.ValueKind != JsonValueKind.Array || json.GetArrayLength() == 0) + { + ConsoleFormatter.PrintWarning("No milestones yet."); + return; + } + + Console.WriteLine($"\n Milestones ({json.GetArrayLength()}):\n"); + foreach (var item in json.EnumerateArray()) + { + var type = item.GetPropertyOrDefault("type", "?"); + var desc = item.GetPropertyOrDefault("description", ""); + var color = type switch + { + "First" => ConsoleColor.Cyan, + "Streak" => ConsoleColor.Yellow, + "Improvement" => ConsoleColor.Green, + _ => ConsoleColor.Gray + }; + Console.ForegroundColor = color; + Console.Write($" [{type}] "); + Console.ResetColor(); + Console.WriteLine(desc); + } + Console.WriteLine(); + }); + + var resetCmd = new Command("reset", "Wipe all growth data"); + resetCmd.SetAction(async (pr) => + { + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) { ConsoleFormatter.PrintError("Daemon is not running."); return; } + ConsoleFormatter.PrintWarning("Growth reset requires direct database access. Use the Database page in the dashboard."); + }); + + Add(milestonesCmd); + Add(resetCmd); + SetAction(Execute); + } + + private async Task Execute(ParseResult pr) + { + var client = new DevBrainHttpClient(); + if (!await client.IsHealthy()) + { + ConsoleFormatter.PrintError("Daemon is not running."); + return; + } + + try + { + var json = await client.GetJson("/api/v1/growth"); + + if (json.TryGetProperty("status", out var status) && status.GetString() == "no_data") + { + ConsoleFormatter.PrintWarning("No growth reports yet. The growth agent runs weekly (Monday 8 AM)."); + return; + } + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(" Growth Report"); + Console.ResetColor(); + + if (json.TryGetProperty("narrative", out var narr) && narr.ValueKind == JsonValueKind.String) + { + Console.WriteLine(); + Console.WriteLine($" {narr.GetString()}"); + } + + Console.WriteLine(); + + if (json.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array) + { + foreach (var m in metrics.EnumerateArray()) + { + var dim = m.GetPropertyOrDefault("dimension", "?"); + var val = m.TryGetProperty("value", out var v) ? v.GetDouble() : 0; + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($" {dim,-25}"); + Console.ResetColor(); + Console.WriteLine($"{val:F2}"); + } + } + + Console.WriteLine(); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to fetch growth report: {ex.Message}"); + } + } +} diff --git a/src/DevBrain.Cli/Program.cs b/src/DevBrain.Cli/Program.cs index 026e51f..1a8dbd7 100644 --- a/src/DevBrain.Cli/Program.cs +++ b/src/DevBrain.Cli/Program.cs @@ -16,6 +16,7 @@ root.Add(new StoryCommand()); root.Add(new ReplayCommand()); root.Add(new BlastCommand()); +root.Add(new GrowthCommand()); root.Add(new RelatedCommand()); root.Add(new AgentsCommand()); root.Add(new ConfigCommand()); diff --git a/src/DevBrain.Core/Interfaces/IGrowthStore.cs b/src/DevBrain.Core/Interfaces/IGrowthStore.cs new file mode 100644 index 0000000..0e7c2fe --- /dev/null +++ b/src/DevBrain.Core/Interfaces/IGrowthStore.cs @@ -0,0 +1,19 @@ +namespace DevBrain.Core.Interfaces; + +using DevBrain.Core.Models; + +public interface IGrowthStore +{ + Task AddMetric(DeveloperMetric metric); + Task> GetMetrics(string dimension, int weeks = 12); + Task> GetLatestMetrics(); + + Task AddMilestone(GrowthMilestone milestone); + Task> GetMilestones(int limit = 50); + + Task AddReport(GrowthReport report); + Task GetLatestReport(); + Task> GetReports(int limit = 12); + + Task Clear(); +} diff --git a/src/DevBrain.Storage/SqliteGrowthStore.cs b/src/DevBrain.Storage/SqliteGrowthStore.cs new file mode 100644 index 0000000..d2fea1f --- /dev/null +++ b/src/DevBrain.Storage/SqliteGrowthStore.cs @@ -0,0 +1,190 @@ +namespace DevBrain.Storage; + +using System.Globalization; +using System.Text.Json; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using Microsoft.Data.Sqlite; + +public class SqliteGrowthStore : IGrowthStore +{ + private readonly SqliteConnection _connection; + + public SqliteGrowthStore(SqliteConnection connection) + { + _connection = connection; + } + + public async Task AddMetric(DeveloperMetric metric) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO developer_metrics (id, dimension, value, period_start, period_end, created_at) + VALUES (@id, @dimension, @value, @periodStart, @periodEnd, @createdAt) + """; + cmd.Parameters.AddWithValue("@id", metric.Id); + cmd.Parameters.AddWithValue("@dimension", metric.Dimension); + cmd.Parameters.AddWithValue("@value", metric.Value); + cmd.Parameters.AddWithValue("@periodStart", metric.PeriodStart.ToString("o")); + cmd.Parameters.AddWithValue("@periodEnd", metric.PeriodEnd.ToString("o")); + cmd.Parameters.AddWithValue("@createdAt", metric.CreatedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + return metric; + } + + public async Task> GetMetrics(string dimension, int weeks = 12) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + SELECT * FROM developer_metrics + WHERE dimension = @dimension + ORDER BY period_start DESC LIMIT @limit + """; + cmd.Parameters.AddWithValue("@dimension", dimension); + cmd.Parameters.AddWithValue("@limit", weeks); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapMetric(reader)); + return results; + } + + public async Task> GetLatestMetrics() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + SELECT m.* FROM developer_metrics m + INNER JOIN ( + SELECT dimension, MAX(period_start) as max_start + FROM developer_metrics GROUP BY dimension + ) latest ON m.dimension = latest.dimension AND m.period_start = latest.max_start + ORDER BY m.dimension + """; + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapMetric(reader)); + return results; + } + + public async Task AddMilestone(GrowthMilestone milestone) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO milestones (id, type, description, achieved_at, observation_id, created_at) + VALUES (@id, @type, @description, @achievedAt, @observationId, @createdAt) + """; + cmd.Parameters.AddWithValue("@id", milestone.Id); + cmd.Parameters.AddWithValue("@type", milestone.Type.ToString()); + cmd.Parameters.AddWithValue("@description", milestone.Description); + cmd.Parameters.AddWithValue("@achievedAt", milestone.AchievedAt.ToString("o")); + cmd.Parameters.AddWithValue("@observationId", (object?)milestone.ObservationId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@createdAt", milestone.CreatedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + return milestone; + } + + public async Task> GetMilestones(int limit = 50) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM milestones ORDER BY achieved_at DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", limit); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapMilestone(reader)); + return results; + } + + public async Task AddReport(GrowthReport report) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO growth_reports (id, period_start, period_end, metrics, milestones, narrative, generated_at) + VALUES (@id, @periodStart, @periodEnd, @metrics, @milestones, @narrative, @generatedAt) + """; + cmd.Parameters.AddWithValue("@id", report.Id); + cmd.Parameters.AddWithValue("@periodStart", report.PeriodStart.ToString("o")); + cmd.Parameters.AddWithValue("@periodEnd", report.PeriodEnd.ToString("o")); + cmd.Parameters.AddWithValue("@metrics", JsonSerializer.Serialize(report.Metrics.Select(m => m.Id))); + cmd.Parameters.AddWithValue("@milestones", JsonSerializer.Serialize(report.Milestones.Select(m => m.Id))); + cmd.Parameters.AddWithValue("@narrative", (object?)report.Narrative ?? DBNull.Value); + cmd.Parameters.AddWithValue("@generatedAt", report.GeneratedAt.ToString("o")); + await cmd.ExecuteNonQueryAsync(); + return report; + } + + public async Task GetLatestReport() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM growth_reports ORDER BY generated_at DESC LIMIT 1"; + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapReport(reader); + return null; + } + + public async Task> GetReports(int limit = 12) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM growth_reports ORDER BY generated_at DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", limit); + + var results = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + results.Add(MapReport(reader)); + return results; + } + + public async Task Clear() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "DELETE FROM developer_metrics; DELETE FROM milestones; DELETE FROM growth_reports;"; + await cmd.ExecuteNonQueryAsync(); + } + + private static DeveloperMetric MapMetric(SqliteDataReader reader) => new() + { + Id = reader.GetString(reader.GetOrdinal("id")), + Dimension = reader.GetString(reader.GetOrdinal("dimension")), + Value = reader.GetDouble(reader.GetOrdinal("value")), + PeriodStart = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_start")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + PeriodEnd = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_end")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; + + private static GrowthMilestone MapMilestone(SqliteDataReader reader) => new() + { + Id = reader.GetString(reader.GetOrdinal("id")), + Type = Enum.Parse(reader.GetString(reader.GetOrdinal("type"))), + Description = reader.GetString(reader.GetOrdinal("description")), + AchievedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("achieved_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + ObservationId = reader.IsDBNull(reader.GetOrdinal("observation_id")) + ? null : reader.GetString(reader.GetOrdinal("observation_id")), + CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; + + private static GrowthReport MapReport(SqliteDataReader reader) => new() + { + Id = reader.GetString(reader.GetOrdinal("id")), + PeriodStart = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_start")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + PeriodEnd = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_end")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + Narrative = reader.IsDBNull(reader.GetOrdinal("narrative")) + ? null : reader.GetString(reader.GetOrdinal("narrative")), + GeneratedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("generated_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; +} diff --git a/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs b/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs new file mode 100644 index 0000000..c4f843d --- /dev/null +++ b/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs @@ -0,0 +1,182 @@ +using DevBrain.Agents; +using DevBrain.Core.Enums; +using DevBrain.Core.Interfaces; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Agents.Tests; + +public class GrowthAgentTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteObservationStore _obsStore = null!; + private SqliteGraphStore _graphStore = null!; + private SqliteDeadEndStore _deadEndStore = null!; + private SqliteGrowthStore _growthStore = null!; + private GrowthAgent _agent = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _obsStore = new SqliteObservationStore(_connection); + _graphStore = new SqliteGraphStore(_connection); + _deadEndStore = new SqliteDeadEndStore(_connection); + _growthStore = new SqliteGrowthStore(_connection); + _agent = new GrowthAgent(_growthStore); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + private AgentContext CreateContext() + { + return new AgentContext( + Observations: _obsStore, + Graph: _graphStore, + Vectors: new NullVectorStore(), + Llm: new NullLlmService(), + Settings: new Settings(), + DeadEnds: _deadEndStore + ); + } + + [Fact] + public async Task Run_GeneratesMetricsAndReport() + { + var now = DateTime.UtcNow; + for (int i = 0; i < 10; i++) + { + await _obsStore.Add(new Observation + { + Id = $"obs-{i}", SessionId = "s1", ThreadId = "t1", + Timestamp = now.AddMinutes(-60 + i * 5), Project = "proj", + EventType = i % 3 == 0 ? EventType.Error : EventType.FileChange, + Source = CaptureSource.ClaudeCode, + RawContent = $"Activity {i}", + FilesInvolved = [$"src/File{i % 3}.cs"] + }); + } + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Contains(results, r => r.Type == AgentOutputType.GrowthReportGenerated); + + var report = await _growthStore.GetLatestReport(); + Assert.NotNull(report); + + var metrics = await _growthStore.GetLatestMetrics(); + Assert.True(metrics.Count >= 6); + } + + [Fact] + public async Task Run_SkipsWhenNoObservations() + { + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Empty(results); + Assert.Null(await _growthStore.GetLatestReport()); + } + + [Fact] + public void ComputeDebuggingSpeed_MeasuresErrorDuration() + { + var now = DateTime.UtcNow; + var obs = new List + { + MakeObs("1", "t1", now, EventType.Error), + MakeObs("2", "t1", now.AddMinutes(10), EventType.Error), + MakeObs("3", "t1", now.AddMinutes(15), EventType.FileChange), + }; + + var speed = GrowthAgent.ComputeDebuggingSpeed(obs); + Assert.Equal(10.0, speed); + } + + [Fact] + public void ComputeDecisionVelocity_MeasuresFileChangeToDecision() + { + var now = DateTime.UtcNow; + var obs = new List + { + MakeObs("1", "t1", now, EventType.FileChange), + MakeObs("2", "t1", now.AddMinutes(20), EventType.Decision), + }; + + var velocity = GrowthAgent.ComputeDecisionVelocity(obs); + Assert.Equal(20.0, velocity); + } + + [Fact] + public void ComputeRetryRate_DetectsRepeatedEdits() + { + var now = DateTime.UtcNow; + var obs = new List + { + MakeObs("1", "t1", now, EventType.FileChange, files: ["src/A.cs"]), + MakeObs("2", "t1", now.AddMinutes(1), EventType.FileChange, files: ["src/A.cs"]), + MakeObs("3", "t1", now.AddMinutes(2), EventType.FileChange, files: ["src/A.cs"]), + }; + + var rate = GrowthAgent.ComputeRetryRate(obs); + Assert.Equal(1.0, rate); // 100% of sessions have retries + } + + [Fact] + public void ComputeHeuristicComplexity_ReturnsNormalizedScore() + { + var now = DateTime.UtcNow; + var obs = new List + { + MakeObs("1", "t1", now, EventType.FileChange, files: ["a.cs", "b.cs", "c.cs"]), + MakeObs("2", "t1", now.AddHours(2), EventType.Decision), + MakeObs("3", "t1", now.AddHours(3), EventType.FileChange, files: ["d.cs"]), + }; + + var complexity = GrowthAgent.ComputeHeuristicComplexity(obs); + Assert.InRange(complexity, 1.0, 5.0); + } + + [Fact] + public async Task Run_DetectsFirstProjectMilestone() + { + var now = DateTime.UtcNow; + await _obsStore.Add(new Observation + { + Id = "obs-new", SessionId = "s1", ThreadId = "t1", + Timestamp = now.AddMinutes(-10), Project = "brand-new-project", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "First work on new project", + FilesInvolved = ["src/App.cs"] + }); + + var ctx = CreateContext(); + var results = await _agent.Run(ctx, CancellationToken.None); + + Assert.Contains(results, r => + r.Type == AgentOutputType.MilestoneAchieved && + r.Content.Contains("brand-new-project")); + + var milestones = await _growthStore.GetMilestones(); + Assert.Contains(milestones, m => + m.Type == MilestoneType.First && + m.Description.Contains("brand-new-project")); + } + + private static Observation MakeObs(string id, string threadId, DateTime timestamp, + EventType type, string[]? files = null) => new() + { + Id = id, SessionId = "s1", ThreadId = threadId, + Timestamp = timestamp, Project = "proj", + EventType = type, Source = CaptureSource.ClaudeCode, + RawContent = $"Content for {id}", + FilesInvolved = files ?? [] + }; +} diff --git a/tests/DevBrain.Storage.Tests/SqliteGrowthStoreTests.cs b/tests/DevBrain.Storage.Tests/SqliteGrowthStoreTests.cs new file mode 100644 index 0000000..f526f9a --- /dev/null +++ b/tests/DevBrain.Storage.Tests/SqliteGrowthStoreTests.cs @@ -0,0 +1,121 @@ +using DevBrain.Core.Enums; +using DevBrain.Core.Models; +using DevBrain.Storage; +using DevBrain.Storage.Schema; +using Microsoft.Data.Sqlite; + +namespace DevBrain.Storage.Tests; + +public class SqliteGrowthStoreTests : IAsyncLifetime +{ + private SqliteConnection _connection = null!; + private SqliteGrowthStore _store = null!; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + SchemaManager.Initialize(_connection); + _store = new SqliteGrowthStore(_connection); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } + + [Fact] + public async Task AddMetric_And_GetMetrics_RoundTrips() + { + var now = DateTime.UtcNow; + await _store.AddMetric(new DeveloperMetric + { + Id = "m1", Dimension = "debugging_speed", Value = 12.5, + PeriodStart = now.AddDays(-7), PeriodEnd = now + }); + + var metrics = await _store.GetMetrics("debugging_speed"); + Assert.Single(metrics); + Assert.Equal(12.5, metrics[0].Value); + } + + [Fact] + public async Task GetLatestMetrics_ReturnsOnePerDimension() + { + var now = DateTime.UtcNow; + await _store.AddMetric(new DeveloperMetric + { + Id = "m1", Dimension = "debugging_speed", Value = 10, + PeriodStart = now.AddDays(-14), PeriodEnd = now.AddDays(-7) + }); + await _store.AddMetric(new DeveloperMetric + { + Id = "m2", Dimension = "debugging_speed", Value = 8, + PeriodStart = now.AddDays(-7), PeriodEnd = now + }); + await _store.AddMetric(new DeveloperMetric + { + Id = "m3", Dimension = "dead_end_rate", Value = 0.5, + PeriodStart = now.AddDays(-7), PeriodEnd = now + }); + + var latest = await _store.GetLatestMetrics(); + Assert.Equal(2, latest.Count); + Assert.Contains(latest, m => m.Dimension == "debugging_speed" && m.Value == 8); + Assert.Contains(latest, m => m.Dimension == "dead_end_rate"); + } + + [Fact] + public async Task AddMilestone_And_GetMilestones_RoundTrips() + { + await _store.AddMilestone(new GrowthMilestone + { + Id = "ms1", Type = MilestoneType.First, + Description = "First time using CTE queries", + AchievedAt = DateTime.UtcNow + }); + + var milestones = await _store.GetMilestones(); + Assert.Single(milestones); + Assert.Equal(MilestoneType.First, milestones[0].Type); + Assert.Contains("CTE", milestones[0].Description); + } + + [Fact] + public async Task AddReport_And_GetLatest_RoundTrips() + { + await _store.AddReport(new GrowthReport + { + Id = "r1", + PeriodStart = DateTime.UtcNow.AddDays(-7), + PeriodEnd = DateTime.UtcNow, + Narrative = "Great week — debugging speed improved 20%" + }); + + var latest = await _store.GetLatestReport(); + Assert.NotNull(latest); + Assert.Equal("r1", latest.Id); + Assert.Contains("debugging speed", latest.Narrative); + } + + [Fact] + public async Task Clear_RemovesAllData() + { + await _store.AddMetric(new DeveloperMetric + { + Id = "m1", Dimension = "test", Value = 1, + PeriodStart = DateTime.UtcNow, PeriodEnd = DateTime.UtcNow + }); + await _store.AddMilestone(new GrowthMilestone + { + Id = "ms1", Type = MilestoneType.First, + Description = "test", AchievedAt = DateTime.UtcNow + }); + + await _store.Clear(); + + Assert.Empty(await _store.GetLatestMetrics()); + Assert.Empty(await _store.GetMilestones()); + Assert.Null(await _store.GetLatestReport()); + } +} From 4244ee8815b27adac154f306548f8d4d952c244a Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:23:13 -0700 Subject: [PATCH 21/36] fix: address Growth Tracker review findings Critical: - Fix MapReport to hydrate Metrics and Milestones from stored IDs (was returning empty collections, making dashboard non-functional) - Fix ComputeDebuggingSpeed to measure first-error-to-resolution instead of error span (lastError - firstError) - Rework complexity normalization: 1.0 + raw with better weights so scores actually vary across thread sizes (was clamping all to 1.0) High: - Detect milestones BEFORE persisting metrics to avoid consuming history slots with current week's data - Add Before filter to weekly observation query - Reduce historical project query from 5000 to 500 Medium: - Add DELETE /growth endpoint for reset functionality - Fix CLI reset command to use API instead of dead-end message Low: - Add streak milestone detection for zero dead-end weeks - 5 new tests: error-to-resolution, zero errors, complexity variation, report round-trip with hydrated metrics, multi-session retry rate - Fix existing debugging speed test for new correct behavior All 114 tests passing, dashboard builds clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Agents/GrowthAgent.cs | 59 +++++++--- src/DevBrain.Api/Endpoints/GrowthEndpoints.cs | 6 + src/DevBrain.Cli/Commands/GrowthCommand.cs | 6 +- src/DevBrain.Storage/SqliteGrowthStore.cs | 65 ++++++++--- .../DevBrain.Agents.Tests/GrowthAgentTests.cs | 107 +++++++++++++++++- 5 files changed, 211 insertions(+), 32 deletions(-) diff --git a/src/DevBrain.Agents/GrowthAgent.cs b/src/DevBrain.Agents/GrowthAgent.cs index fa5ce01..9af4849 100644 --- a/src/DevBrain.Agents/GrowthAgent.cs +++ b/src/DevBrain.Agents/GrowthAgent.cs @@ -31,7 +31,8 @@ public async Task> Run(AgentContext ctx, Cancellation var weekObs = await ctx.Observations.Query(new ObservationFilter { After = periodStart, - Limit = 1000 + Before = periodEnd, + Limit = 2000 }); if (weekObs.Count == 0) @@ -83,12 +84,14 @@ public async Task> Run(AgentContext ctx, Cancellation var quality = weekObs.Count > 0 ? 1.0 - ((double)errorCount / weekObs.Count) : 1.0; metrics.Add(CreateMetric("code_quality", Math.Round(quality, 3), periodStart, periodEnd)); - // Persist metrics + // Detect milestones BEFORE persisting metrics so history queries + // don't include current week's data (avoids consuming a history slot) + var milestones = await DetectMilestones(ctx, weekObs, metrics, periodStart); + + // Now persist metrics foreach (var metric in metrics) await _growthStore.AddMetric(metric); - // Detect milestones - var milestones = await DetectMilestones(ctx, weekObs, metrics, periodStart); foreach (var milestone in milestones) { await _growthStore.AddMilestone(milestone); @@ -149,11 +152,12 @@ internal static double ComputeDebuggingSpeed(IReadOnlyList observat var lastError = sorted.LastOrDefault(o => o.EventType == EventType.Error); if (lastError is null) continue; - var hasSubsequentWork = sorted - .Any(o => o.Timestamp > lastError.Timestamp && o.EventType != EventType.Error); + // Resolution = first non-error observation after the last error + var resolution = sorted + .FirstOrDefault(o => o.Timestamp > lastError.Timestamp && o.EventType != EventType.Error); - if (hasSubsequentWork) - durations.Add((lastError.Timestamp - firstError.Timestamp).TotalMinutes); + if (resolution is not null) + durations.Add((resolution.Timestamp - firstError.Timestamp).TotalMinutes); } return durations.Count > 0 ? durations.Average() : 0; @@ -216,8 +220,11 @@ internal static double ComputeHeuristicComplexity(IReadOnlyList obs : 0; var crossProjectRefs = sorted.Select(o => o.Project).Distinct().Count(); - var raw = (filesInvolved * 0.3) + (decisions * 0.25) + (durationHours * 0.2) + (crossProjectRefs * 0.25); - scores.Add(Math.Clamp(raw / 4.0, 1.0, 5.0)); + // Scale: 1 file + 0 decisions + 0 hours + 1 project = ~1.0 + // 5 files + 2 decisions + 1 hour + 1 project = ~3.0 + // 15+ files + 5+ decisions + 3+ hours + 3+ projects = ~5.0 + var raw = (filesInvolved * 0.3) + (decisions * 0.5) + (durationHours * 0.4) + (crossProjectRefs * 0.3); + scores.Add(Math.Clamp(1.0 + raw, 1.0, 5.0)); } return scores.Count > 0 ? scores.Average() : 1.0; @@ -231,9 +238,10 @@ private async Task> DetectMilestones( // "First" milestones: new projects var currentProjects = weekObs.Select(o => o.Project).Distinct().ToList(); + // Use targeted query for historical projects instead of loading 5000 observations var historicalObs = await ctx.Observations.Query(new ObservationFilter { - Before = periodStart, Limit = 5000 + Before = periodStart, Limit = 500 }); var historicalProjects = historicalObs.Select(o => o.Project).Distinct().ToHashSet(); @@ -251,13 +259,32 @@ private async Task> DetectMilestones( } } + // "Streak" milestones: zero dead ends this week + var deadEndMetric = currentMetrics.FirstOrDefault(m => m.Dimension == "dead_end_rate"); + if (deadEndMetric is not null && deadEndMetric.Value == 0) + { + var deadEndHistory = await _growthStore.GetMetrics("dead_end_rate", 4); + var priorWeeksWithDeadEnds = deadEndHistory.Count(m => m.Value > 0); + + if (priorWeeksWithDeadEnds > 0 || deadEndHistory.Count == 0) + { + milestones.Add(new GrowthMilestone + { + Id = Guid.NewGuid().ToString(), + Type = MilestoneType.Streak, + Description = "Zero dead ends this week", + AchievedAt = DateTime.UtcNow + }); + } + } + // "Improvement" milestones: any metric > 20% better than 4-week average foreach (var metric in currentMetrics) { var history = await _growthStore.GetMetrics(metric.Dimension, weeks: 4); - if (history.Count < 2) continue; + if (history.Count == 0) continue; - var avg = history.Where(m => m.Id != metric.Id).Select(m => m.Value).DefaultIfEmpty(0).Average(); + var avg = history.Select(m => m.Value).Average(); if (avg == 0) continue; // For rate metrics (dead_end_rate, retry_rate), lower is better @@ -286,10 +313,10 @@ private async Task> DetectMilestones( var complexityHistory = await _growthStore.GetMetrics("problem_complexity", 4); var qualityHistory = await _growthStore.GetMetrics("code_quality", 4); - if (complexityHistory.Count >= 2 && qualityHistory.Count >= 2) + if (complexityHistory.Count >= 1 && qualityHistory.Count >= 1) { - var complexityAvg = complexityHistory.Where(m => m.Id != complexityMetric.Id).Select(m => m.Value).Average(); - var qualityAvg = qualityHistory.Where(m => m.Id != qualityMetric.Id).Select(m => m.Value).Average(); + var complexityAvg = complexityHistory.Select(m => m.Value).Average(); + var qualityAvg = qualityHistory.Select(m => m.Value).Average(); var complexityUp = complexityAvg > 0 && (complexityMetric.Value - complexityAvg) / complexityAvg > 0.10; var qualityStable = qualityAvg > 0 && Math.Abs(qualityMetric.Value - qualityAvg) / qualityAvg <= 0.05; diff --git a/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs b/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs index cdcc30b..3f99d9d 100644 --- a/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs +++ b/src/DevBrain.Api/Endpoints/GrowthEndpoints.cs @@ -33,5 +33,11 @@ public static void MapGrowthEndpoints(this WebApplication app) var milestones = await growthStore.GetMilestones(Math.Min(limit ?? 50, 200)); return Results.Ok(milestones); }); + + group.MapDelete("/", async (IGrowthStore growthStore) => + { + await growthStore.Clear(); + return Results.Ok(new { cleared = true }); + }); } } diff --git a/src/DevBrain.Cli/Commands/GrowthCommand.cs b/src/DevBrain.Cli/Commands/GrowthCommand.cs index 7dcaf6e..17204ee 100644 --- a/src/DevBrain.Cli/Commands/GrowthCommand.cs +++ b/src/DevBrain.Cli/Commands/GrowthCommand.cs @@ -46,7 +46,11 @@ public GrowthCommand() : base("growth", "Show developer growth report") { var client = new DevBrainHttpClient(); if (!await client.IsHealthy()) { ConsoleFormatter.PrintError("Daemon is not running."); return; } - ConsoleFormatter.PrintWarning("Growth reset requires direct database access. Use the Database page in the dashboard."); + var response = await client.Delete("/api/v1/growth"); + if (response.IsSuccessStatusCode) + ConsoleFormatter.PrintSuccess("All growth data has been cleared."); + else + ConsoleFormatter.PrintError("Failed to clear growth data."); }); Add(milestonesCmd); diff --git a/src/DevBrain.Storage/SqliteGrowthStore.cs b/src/DevBrain.Storage/SqliteGrowthStore.cs index d2fea1f..1819585 100644 --- a/src/DevBrain.Storage/SqliteGrowthStore.cs +++ b/src/DevBrain.Storage/SqliteGrowthStore.cs @@ -125,7 +125,7 @@ INSERT INTO growth_reports (id, period_start, period_end, metrics, milestones, n using var reader = await cmd.ExecuteReaderAsync(); if (await reader.ReadAsync()) - return MapReport(reader); + return await HydrateReport(MapReportShell(reader)); return null; } @@ -138,10 +138,38 @@ public async Task> GetReports(int limit = 12) var results = new List(); using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) - results.Add(MapReport(reader)); + results.Add(await HydrateReport(MapReportShell(reader))); return results; } + private async Task HydrateReport( + (GrowthReport Report, List MetricIds, List MilestoneIds) shell) + { + var metrics = new List(); + foreach (var id in shell.MetricIds) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM developer_metrics WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + metrics.Add(MapMetric(reader)); + } + + var milestones = new List(); + foreach (var id in shell.MilestoneIds) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM milestones WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + milestones.Add(MapMilestone(reader)); + } + + return shell.Report with { Metrics = metrics, Milestones = milestones }; + } + public async Task Clear() { using var cmd = _connection.CreateCommand(); @@ -175,16 +203,27 @@ public async Task Clear() CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) }; - private static GrowthReport MapReport(SqliteDataReader reader) => new() + private static (GrowthReport Report, List MetricIds, List MilestoneIds) MapReportShell( + SqliteDataReader reader) { - Id = reader.GetString(reader.GetOrdinal("id")), - PeriodStart = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_start")), - CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), - PeriodEnd = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_end")), - CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), - Narrative = reader.IsDBNull(reader.GetOrdinal("narrative")) - ? null : reader.GetString(reader.GetOrdinal("narrative")), - GeneratedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("generated_at")), - CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) - }; + var metricIds = JsonSerializer.Deserialize>( + reader.GetString(reader.GetOrdinal("metrics"))) ?? []; + var milestoneIds = JsonSerializer.Deserialize>( + reader.GetString(reader.GetOrdinal("milestones"))) ?? []; + + var report = new GrowthReport + { + Id = reader.GetString(reader.GetOrdinal("id")), + PeriodStart = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_start")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + PeriodEnd = DateTime.Parse(reader.GetString(reader.GetOrdinal("period_end")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + Narrative = reader.IsDBNull(reader.GetOrdinal("narrative")) + ? null : reader.GetString(reader.GetOrdinal("narrative")), + GeneratedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("generated_at")), + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + }; + + return (report, metricIds, milestoneIds); + } } diff --git a/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs b/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs index c4f843d..7f59293 100644 --- a/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs +++ b/tests/DevBrain.Agents.Tests/GrowthAgentTests.cs @@ -86,7 +86,7 @@ public async Task Run_SkipsWhenNoObservations() } [Fact] - public void ComputeDebuggingSpeed_MeasuresErrorDuration() + public void ComputeDebuggingSpeed_MeasuresErrorToResolution() { var now = DateTime.UtcNow; var obs = new List @@ -97,7 +97,8 @@ public void ComputeDebuggingSpeed_MeasuresErrorDuration() }; var speed = GrowthAgent.ComputeDebuggingSpeed(obs); - Assert.Equal(10.0, speed); + // First error at t=0, resolution (FileChange after last error) at t=15 + Assert.Equal(15.0, speed); } [Fact] @@ -170,6 +171,108 @@ await _obsStore.Add(new Observation m.Description.Contains("brand-new-project")); } + [Fact] + public void ComputeDebuggingSpeed_MeasuresFirstErrorToResolution() + { + var now = DateTime.UtcNow; + var obs = new List + { + MakeObs("1", "t1", now, EventType.Error), + MakeObs("2", "t1", now.AddMinutes(10), EventType.Error), + MakeObs("3", "t1", now.AddMinutes(30), EventType.FileChange), // resolution + }; + + var speed = GrowthAgent.ComputeDebuggingSpeed(obs); + // Should measure from first error (t=0) to resolution (t=30), not error span (t=10) + Assert.Equal(30.0, speed); + } + + [Fact] + public void ComputeDebuggingSpeed_ZeroErrors_ReturnsZero() + { + var obs = new List + { + MakeObs("1", "t1", DateTime.UtcNow, EventType.FileChange), + }; + + Assert.Equal(0, GrowthAgent.ComputeDebuggingSpeed(obs)); + } + + [Fact] + public void ComputeHeuristicComplexity_VariesAcrossThreadSizes() + { + var now = DateTime.UtcNow; + var simpleObs = new List + { + MakeObs("1", "t1", now, EventType.FileChange, files: ["a.cs"]), + }; + var complexObs = new List + { + MakeObs("1", "t1", now, EventType.FileChange, files: ["a.cs", "b.cs", "c.cs", "d.cs", "e.cs"]), + MakeObs("2", "t1", now.AddHours(2), EventType.Decision), + MakeObs("3", "t1", now.AddHours(3), EventType.Decision), + MakeObs("4", "t1", now.AddHours(4), EventType.FileChange, files: ["f.cs", "g.cs"]), + }; + + var simple = GrowthAgent.ComputeHeuristicComplexity(simpleObs); + var complex = GrowthAgent.ComputeHeuristicComplexity(complexObs); + + Assert.True(complex > simple, + $"Complex ({complex}) should be > Simple ({simple})"); + Assert.InRange(simple, 1.0, 5.0); + Assert.InRange(complex, 1.0, 5.0); + } + + [Fact] + public async Task Run_ReportRoundTrips_WithHydratedMetrics() + { + var now = DateTime.UtcNow; + for (int i = 0; i < 5; i++) + { + await _obsStore.Add(new Observation + { + Id = $"rt-{i}", SessionId = "s1", ThreadId = "t1", + Timestamp = now.AddMinutes(-30 + i * 5), Project = "proj", + EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = $"Activity {i}", FilesInvolved = [$"src/F{i}.cs"] + }); + } + + var ctx = CreateContext(); + await _agent.Run(ctx, CancellationToken.None); + + var report = await _growthStore.GetLatestReport(); + Assert.NotNull(report); + Assert.Equal(8, report.Metrics.Count); // all 8 dimensions + Assert.All(report.Metrics, m => Assert.NotEmpty(m.Dimension)); + } + + [Fact] + public void ComputeRetryRate_MultipleSessions() + { + var now = DateTime.UtcNow; + var obs = new List + { + // Session 1: has retries (3+ edits to same file) + new() { Id = "1", SessionId = "s1", ThreadId = "t1", Timestamp = now, + Project = "proj", EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "edit", FilesInvolved = ["a.cs"] }, + new() { Id = "2", SessionId = "s1", ThreadId = "t1", Timestamp = now.AddMinutes(1), + Project = "proj", EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "edit", FilesInvolved = ["a.cs"] }, + new() { Id = "3", SessionId = "s1", ThreadId = "t1", Timestamp = now.AddMinutes(2), + Project = "proj", EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "edit", FilesInvolved = ["a.cs"] }, + // Session 2: no retries + new() { Id = "4", SessionId = "s2", ThreadId = "t2", Timestamp = now, + Project = "proj", EventType = EventType.FileChange, Source = CaptureSource.ClaudeCode, + RawContent = "edit", FilesInvolved = ["b.cs"] }, + }; + + var rate = GrowthAgent.ComputeRetryRate(obs); + Assert.Equal(0.5, rate); // 1 of 2 sessions has retries + } + private static Observation MakeObs(string id, string threadId, DateTime timestamp, EventType type, string[]? files = null) => new() { From d9e3652805a7d9df1fb0511ab89676a9c9dc7097 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:54:30 -0700 Subject: [PATCH 22/36] docs: add single-click packaging design spec Covers cross-platform package manager distribution (winget, brew, apt), Electron tray app for daemon lifecycle, Ollama auto-bootstrap, and CI/CD pipeline for building and publishing packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-04-07-single-click-packaging-design.md | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-single-click-packaging-design.md diff --git a/docs/superpowers/specs/2026-04-07-single-click-packaging-design.md b/docs/superpowers/specs/2026-04-07-single-click-packaging-design.md new file mode 100644 index 0000000..7a58a9e --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-single-click-packaging-design.md @@ -0,0 +1,565 @@ +# DevBrain Single-Click Packaging Design + +**Date:** 2026-04-07 +**Status:** Draft +**Goal:** Ship DevBrain as a cross-platform package that developers install via package managers with zero manual configuration. + +--- + +## Overview + +DevBrain currently distributes as raw archives (`.tar.gz`/`.zip`) via GitHub Releases, with shell scripts handling installation. This design replaces that with native package manager distribution (`winget`, `brew`, `apt`) backed by an Electron tray app that manages the daemon lifecycle, Ollama bootstrapping, and system integration. + +**Developer experience after this ships:** + +```bash +# macOS +brew tap devbrain/tap && brew install devbrain + +# Windows +winget install DevBrain + +# Linux (Debian/Ubuntu) +curl -s https://devbrain.dev/install.gpg | sudo gpg --dearmor -o /usr/share/keyrings/devbrain.gpg +echo "deb [signed-by=/usr/share/keyrings/devbrain.gpg] https://devbrain.dev/apt stable main" | sudo tee /etc/apt/sources.list.d/devbrain.list +sudo apt update && sudo apt install devbrain +``` + +After install: tray icon appears, daemon starts, Ollama auto-installs in background, everything works. + +--- + +## Architecture + +### Components + +``` +Developer's Machine +┌─────────────────────────────────────────────┐ +│ Electron Tray App (DevBrain.exe / .app) │ +│ ├── System tray icon (green/yellow/red) │ +│ ├── Context menu (start/stop/dashboard) │ +│ ├── Bootstrap orchestrator │ +│ │ ├── Config creation │ +│ │ ├── Ollama auto-install │ +│ │ └── Model pull (llama3.2:3b) │ +│ └── Daemon lifecycle manager │ +│ ├── Spawn devbrain-daemon │ +│ ├── Health poll (/api/v1/health, 5s) │ +│ └── Auto-restart on crash (max 3) │ +│ │ +│ devbrain-daemon (embedded .NET binary) │ +│ ├── HTTP API on 127.0.0.1:37800 │ +│ ├── Capture pipeline (5 stages) │ +│ ├── Agent scheduler (8 agents) │ +│ ├── SQLite database │ +│ └── Dashboard (static files in wwwroot/) │ +│ │ +│ devbrain CLI (embedded .NET binary) │ +│ └── Thin HTTP client to daemon │ +│ │ +│ Ollama (auto-installed, external process) │ +│ └── localhost:11434 │ +└─────────────────────────────────────────────┘ +``` + +### Repository Layout Changes + +New directories added to the monorepo: + +``` +packages/ + tray/ # Electron tray app + package.json # electron, electron-builder deps + electron-builder.yml # Platform-specific build config + src/ + main.ts # Electron entry — tray icon, menu, autostart + bootstrap.ts # First-run: config, Ollama install, model pull + daemon.ts # Spawn/monitor/restart devbrain-daemon + health.ts # Poll health endpoint, update tray icon state + notifications.ts # OS-native notification helpers + assets/ + icon.png # Tray icon (all platforms) + icon.ico # Windows tray icon + icon.icns # macOS tray icon + __tests__/ + bootstrap.test.ts + daemon.test.ts + health.test.ts + + homebrew/ + devbrain.rb # Homebrew formula + + winget/ + DevBrain.DevBrain.yaml # winget manifest + + apt/ + debian/ + control # Package metadata + dependencies + postinst # Post-install: config, Ollama, autostart + prerm # Pre-remove: stop daemon, cleanup + rules # Build rules + +package.json # Root — npm workspaces config +``` + +The `dashboard/` and `packages/tray/` share code via npm workspaces: + +```json +{ + "workspaces": ["dashboard", "packages/tray"] +} +``` + +--- + +## Electron Tray App + +### Tray Icon & Menu + +The tray app is a **menubar utility**, not a windowed application. It manages the daemon and provides quick access. + +**Tray icon states:** +- Green: daemon running, healthy +- Yellow: starting up, bootstrapping, or Ollama installing +- Red: daemon stopped or unhealthy + +**Context menu:** + +``` +DevBrain (Running) ← status text +───────────────── +Open Dashboard → opens http://localhost:37800 in default browser +───────────────── +Start Daemon +Stop Daemon +Restart Daemon +───────────────── +View Logs → opens ~/.devbrain/logs/ in file manager +───────────────── +Quit DevBrain → stops daemon, exits tray app +``` + +### Daemon Lifecycle Management + +The tray app replaces the CLI's `devbrain start`/`devbrain stop` as the primary daemon manager. + +```typescript +// daemon.ts — simplified contract +export interface DaemonManager { + start(): Promise; // Spawn devbrain-daemon, write PID + stop(): Promise; // Kill process, cleanup PID + restart(): Promise; // Stop + start + isRunning(): Promise; // Check PID + health endpoint +} +``` + +**Auto-restart policy:** +- On daemon crash: restart immediately, up to 3 times in 5 minutes +- After 3 failures: stop retrying, show error notification, tray icon turns red +- User can manually restart from tray menu to reset the counter + +**Auto-launch at login:** +- electron-builder's `autoLaunch: true` option handles per-platform registration + - Windows: registry `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` + - macOS: Login Items + - Linux: `~/.config/autostart/devbrain.desktop` + +### CLI / Tray Coordination + +Both the CLI (`devbrain start`/`stop`) and the tray app can manage the daemon. Without coordination, conflicts arise — e.g., `devbrain stop` kills the daemon, tray thinks it crashed, auto-restarts it. + +**Resolution:** The tray app writes a management lock file at `~/.devbrain/tray.lock` while running. The CLI checks for this file: +- `devbrain start` — if `tray.lock` exists and the tray process is alive, print "Daemon is managed by the tray app. Use the tray menu or run `devbrain-tray` to manage it." and exit. +- `devbrain stop` — if `tray.lock` exists and the tray process is alive, send a stop request to the tray app via a local IPC socket (or simply delete the PID file and set a `~/.devbrain/stopped` sentinel). The tray app checks for the sentinel before auto-restarting — if present, it shows "Stopped" state instead of restarting. +- If the tray app is not running (no lock or stale lock), CLI behaves as it does today. + +This keeps the CLI fully functional for headless/server use while preventing fights with the tray app. + +### Dashboard Access + +Tray menu "Open Dashboard" opens `http://localhost:37800` in the system default browser. No embedded Electron BrowserWindow — keeps the tray app lightweight and avoids duplicating browser memory. + +--- + +## First-Run Bootstrap + +Orchestrated by `bootstrap.ts` in the Electron main process. The daemon is NOT responsible for installing external software. + +### Flow + +``` +1. Config check + ~/.devbrain/settings.toml exists? + ├─ YES → skip + └─ NO → create default config (embedded template) + +2. Daemon start + Spawn devbrain-daemon process + Poll /api/v1/health every 500ms, timeout 10s + ├─ SUCCESS → continue + └─ FAIL → show error notification, abort bootstrap + +3. Ollama detection + Probe http://localhost:11434/api/version + ├─ REACHABLE → skip install + └─ UNREACHABLE → install Ollama + ├─ Windows: download OllamaSetup.exe, run /S (silent) + ├─ macOS: brew install ollama || download from ollama.com + └─ Linux: curl -fsSL https://ollama.com/install.sh | sh + Show notification: "Installing local AI model (first time only)..." + +4. Model check + ollama list | grep llama3.2:3b + ├─ FOUND → skip + └─ NOT FOUND → ollama pull llama3.2:3b + Show notification: "Downloading AI model (~2GB)..." + +5. Ready + Tray icon → green + Notification: "DevBrain is ready" +``` + +### Design Principles + +- **Non-blocking:** Daemon starts at step 2. Ollama install (steps 3-4) happens in background. DevBrain is usable immediately — LLM features queue until Ollama is ready. +- **Idempotent:** Every step checks before acting. Running bootstrap 10 times produces the same result. +- **Graceful degradation:** If Ollama install fails, daemon keeps running. Notification suggests cloud LLM fallback via API key in Settings. +- **No internet required:** If offline, daemon runs fine. LLM features deferred until connectivity returns. + +--- + +## Package Manager Distribution + +### Homebrew (macOS + Linux) + +**Tap repository:** `devbrain/homebrew-tap` (separate GitHub repo) + +```ruby +class Devbrain < Formula + desc "Developer's second brain — captures coding sessions, builds knowledge graph" + homepage "https://github.com/devbrain/devbrain" + version "1.0.0" + + if OS.mac? && Hardware::CPU.arm? + url "https://github.com/.../devbrain-osx-arm64.tar.gz" + sha256 "..." + elsif OS.mac? && Hardware::CPU.intel? + url "https://github.com/.../devbrain-osx-x64.tar.gz" + sha256 "..." + elsif OS.linux? && Hardware::CPU.intel? + url "https://github.com/.../devbrain-linux-x64.tar.gz" + sha256 "..." + end + + def install + bin.install "devbrain" + bin.install "devbrain-daemon" + prefix.install "DevBrain.app" if OS.mac? # Electron tray app + end + + # No Homebrew service block — the Electron tray app owns daemon lifecycle. + # Adding a launchd service here would conflict with the tray app on port 37800. + + def post_install + # Tray app handles all user-space bootstrap (config, Ollama, daemon) on first launch. + # Nothing to do here beyond what `install` already did. + end +end +``` + +**Install experience:** +```bash +brew tap devbrain/tap +brew install devbrain +``` + +**Updates:** `brew upgrade devbrain` + +### winget (Windows) + +electron-builder produces an NSIS installer (`.exe`). winget manifest points to it. + +```yaml +PackageIdentifier: DevBrain.DevBrain +PackageVersion: 1.0.0 +PackageName: DevBrain +Publisher: DevBrain +License: Apache-2.0 +InstallerType: exe +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/.../DevBrain-Setup-1.0.0-x64.exe + InstallerSha256: "..." + InstallerSwitches: + Silent: /S + SilentWithProgress: /S +``` + +Submitted via PR to [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs). + +**Install experience:** +```powershell +winget install DevBrain +``` + +**Updates:** `winget upgrade DevBrain` + +### APT (Debian/Ubuntu) + +`.deb` package built from `packages/apt/debian/` control files. Hosted on GitHub Pages as an APT repository. + +``` +Package: devbrain +Version: 1.0.0 +Architecture: amd64 +Depends: libgtk-3-0, libnotify4, libnss3 +Description: Developer's second brain +``` + +Post-install (`postinst`) script: +- Creates symlinks in `/usr/local/bin/` for `devbrain` and `devbrain-daemon` +- Registers desktop autostart entry for the tray app (`/etc/xdg/autostart/devbrain.desktop`) +- Does NOT create user config or install Ollama — runs as root, cannot write to `~/.devbrain/`. The tray app handles all user-space bootstrap on first launch. + +**Install experience:** +```bash +# Add repo (one-time) +curl -s https://devbrain.dev/install.gpg | sudo gpg --dearmor -o /usr/share/keyrings/devbrain.gpg +echo "deb [signed-by=/usr/share/keyrings/devbrain.gpg] https://devbrain.dev/apt stable main" | sudo tee /etc/apt/sources.list.d/devbrain.list + +# Install +sudo apt update && sudo apt install devbrain +``` + +**Updates:** `sudo apt upgrade devbrain` + +**One-liner alternative** (wraps repo setup + install for a simpler experience): +```bash +curl -fsSL https://devbrain.dev/install.sh | sh +``` + +This script adds the GPG key, configures the repo, and runs `apt install devbrain` — reducing the 3-command flow to one. + +--- + +## Uninstall Behavior + +Uninstall (via `winget uninstall`, `brew uninstall`, `apt remove`) should: + +1. **Stop the daemon** — kill the running `devbrain-daemon` process +2. **Stop the tray app** — kill the Electron process +3. **Remove binaries** — CLI, daemon, tray app executables +4. **Remove autostart registration** — registry entry (Windows), Login Items (macOS), `.desktop` file (Linux) +5. **Remove PATH entries** — undo any PATH modifications from install + +Uninstall should **NOT**: +- Delete `~/.devbrain/` — contains the user's knowledge graph, settings, and SQLite database. This is user data. +- Uninstall Ollama — the user may use it for other purposes. DevBrain installed it but doesn't own it. + +If the user wants a full purge, they manually delete `~/.devbrain/`. This follows the convention of tools like Docker, VS Code, and Homebrew itself. + +--- + +## CI/CD Pipeline + +### Build Stages + +``` +Stage 1 (parallel): + ├── Test (.NET) — dotnet test DevBrain.slnx + ├── Dashboard Build — npm ci && npm run build + └── Security Scan — TruffleHog + dependency review + +Stage 2: + └── Build .NET Binaries — 6 platforms (win/mac/linux × x64/arm64) + Self-contained, single-file, PublishSingleFile=true + +Stage 3: + └── Build Electron App — 3 platforms (win-x64, mac-x64, linux-x64) + electron-builder produces: + Windows: NSIS installer (.exe) + macOS: DMG + Linux: .deb + AppImage + Embeds: daemon binary + CLI binary + dashboard static files + +Stage 4 (on v* tag only): + ├── GitHub Release — upload all archives + installers + ├── Homebrew Tap Update — auto-PR to devbrain/homebrew-tap + ├── winget Submission — auto-PR to microsoft/winget-pkgs + └── APT Repo Update — push .deb to GitHub Pages +``` + +### Electron Build Job + +```yaml +electron-build: + needs: [build-dotnet, build-dashboard] + strategy: + matrix: + include: + - os: windows-latest + rid: win-x64 + electron-target: nsis + - os: macos-latest + rid: osx-x64 + electron-target: dmg + - os: ubuntu-latest + rid: linux-x64 + electron-target: deb + steps: + - Download .NET binary artifacts (daemon + CLI for this platform) + - Download dashboard dist/ artifact + - Copy binaries to packages/tray/resources/bin/ + - Copy dashboard to packages/tray/resources/wwwroot/ + - npm ci in packages/tray/ + - npx electron-builder --${{ matrix.electron-target }} + - Upload installer artifact +``` + +### Package Manager Publish Jobs + +- **Homebrew:** [homebrew-releaser](https://github.com/Justintime50/homebrew-releaser) GitHub Action auto-updates formula with new version + SHA256 +- **winget:** [winget-create](https://github.com/microsoft/winget-create) GitHub Action submits PR to microsoft/winget-pkgs +- **APT:** Build `.deb`, sign with GPG key (stored in GitHub Secrets), push to `gh-pages` branch + +### Code Signing + +**Deferred.** Ship unsigned for initial release. Both macOS and Windows will show security warnings — acceptable for early adopters. Add signing later ($220/year, half-day setup) as a single PR without architectural changes. + +--- + +## Embedded Binary Bundling + +The Electron tray app embeds the .NET daemon and CLI binaries inside its package: + +### Windows (NSIS installer) + +``` +C:\Program Files\DevBrain\ + DevBrain.exe # Electron tray app + resources/ + bin/ + devbrain-daemon.exe # .NET daemon + devbrain.exe # .NET CLI + wwwroot/ # Dashboard static files +``` + +NSIS post-install adds `C:\Program Files\DevBrain\resources\bin\` to user PATH. + +### macOS (DMG) + +``` +/Applications/DevBrain.app/ + Contents/ + MacOS/ + DevBrain # Electron binary + Resources/ + bin/ + devbrain-daemon # .NET daemon + devbrain # .NET CLI + wwwroot/ # Dashboard static files +``` + +Homebrew formula symlinks CLI binaries to `/usr/local/bin/`. + +### Linux (.deb) + +``` +/opt/devbrain/ + devbrain-tray # Electron tray app + resources/ + bin/ + devbrain-daemon # .NET daemon + devbrain # .NET CLI + wwwroot/ # Dashboard static files +/usr/local/bin/ + devbrain -> /opt/devbrain/resources/bin/devbrain + devbrain-daemon -> /opt/devbrain/resources/bin/devbrain-daemon +``` + +--- + +## Testing + +### Unit Tests (Jest) + +``` +packages/tray/__tests__/ + bootstrap.test.ts # Mock Ollama probe, mock filesystem, verify decision tree + daemon.test.ts # Mock child_process.spawn, simulate health responses, verify restart logic + health.test.ts # Feed health states, assert correct icon/tooltip transitions +``` + +Key scenarios: +- Bootstrap skips Ollama install when already present +- Bootstrap creates config only when missing (idempotent) +- Daemon restarts on crash, stops after 3 failures in 5 minutes +- Health transitions: starting→healthy, healthy→unhealthy, unhealthy→healthy +- Ollama install failure falls back gracefully (notification, no crash) + +### CI Smoke Tests + +New job that runs on each platform VM after package build: + +1. Install the built package (NSIS/DMG/.deb) +2. Verify `devbrain --version` outputs correctly +3. Verify `devbrain-daemon` starts and responds on `/api/v1/health` +4. Verify tray app process launches without crash (exit code 0 after 10s) +5. Verify uninstall cleans up (PATH, autostart, but preserves `~/.devbrain/` data) + +### Existing Tests + +No changes to existing 54 xUnit tests. Daemon and CLI behavior unchanged. + +--- + +## Out of Scope (v1) + +| Item | Rationale | +|------|-----------| +| Code signing | Ship unsigned, add as a follow-up PR ($220/year, half-day setup) | +| Electron autoUpdater | Package managers handle updates | +| In-app settings UI in tray | Dashboard Settings page already exists | +| ARM64 Electron builds | Start x64 only. Add ARM64 macOS when signing is in place | +| Flatpak / Snap | APT + AppImage covers Linux | +| CLI shell completions | Nice-to-have, separate effort | +| Multi-user / team features | DevBrain is a single-dev tool | + +--- + +## File Inventory + +New files to create: + +| File | Purpose | +|------|---------| +| `package.json` (root) | npm workspaces config | +| `packages/tray/package.json` | Electron + electron-builder deps | +| `packages/tray/electron-builder.yml` | Platform build config, autoLaunch, binary paths | +| `packages/tray/tsconfig.json` | TypeScript config for tray app | +| `packages/tray/src/main.ts` | Electron entry — tray icon, context menu | +| `packages/tray/src/bootstrap.ts` | First-run orchestrator | +| `packages/tray/src/daemon.ts` | Daemon spawn/monitor/restart | +| `packages/tray/src/health.ts` | Health polling, tray icon state machine | +| `packages/tray/src/notifications.ts` | OS notification helpers | +| `packages/tray/assets/icon.png` | Tray icon (base) | +| `packages/tray/assets/icon.ico` | Windows tray icon | +| `packages/tray/assets/icon.icns` | macOS tray icon | +| `packages/tray/__tests__/bootstrap.test.ts` | Bootstrap unit tests | +| `packages/tray/__tests__/daemon.test.ts` | Daemon lifecycle tests | +| `packages/tray/__tests__/health.test.ts` | Health state tests | +| `packages/homebrew/devbrain.rb` | Homebrew formula | +| `packages/winget/DevBrain.DevBrain.yaml` | winget manifest | +| `packages/apt/debian/control` | Debian package metadata | +| `packages/apt/debian/postinst` | Post-install script | +| `packages/apt/debian/prerm` | Pre-remove script | +| `packages/apt/debian/rules` | Build rules | +| `.github/workflows/package.yml` | Electron build + package manager publish | + +Files to modify: + +| File | Change | +|------|--------| +| `.github/workflows/build.yml` | Add electron build stage dependency | +| `.gitignore` | Add `packages/tray/dist/`, `packages/tray/node_modules/` | From 40d23ded39a42674c271f4150289cfe1a6e3cb9c Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:07:28 -0700 Subject: [PATCH 23/36] docs: add single-click packaging implementation plan 12 tasks covering Electron tray app (health, daemon, bootstrap), CLI/tray coordination, electron-builder config, package manager manifests (Homebrew, winget, APT), and CI/CD pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-07-single-click-packaging.md | 2114 +++++++++++++++++ 1 file changed, 2114 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-single-click-packaging.md diff --git a/docs/superpowers/plans/2026-04-07-single-click-packaging.md b/docs/superpowers/plans/2026-04-07-single-click-packaging.md new file mode 100644 index 0000000..0305afe --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-single-click-packaging.md @@ -0,0 +1,2114 @@ +# Single-Click Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship DevBrain as cross-platform packages (winget, brew, apt) with an Electron tray app that manages daemon lifecycle, Ollama bootstrapping, and auto-launch at login. + +**Architecture:** An Electron menubar app embeds the existing .NET daemon + CLI binaries, manages their lifecycle, and handles first-run setup (config creation, Ollama install). Package managers (winget, Homebrew, APT) distribute the Electron app as an NSIS installer, DMG, or .deb. CI/CD builds everything and publishes to package registries on tagged releases. + +**Tech Stack:** Electron 36+, TypeScript, electron-builder, Jest, GitHub Actions, Homebrew formula, winget manifest, Debian packaging. + +**Spec:** `docs/superpowers/specs/2026-04-07-single-click-packaging-design.md` + +--- + +## File Structure + +### New Files + +``` +package.json # Root npm workspaces config +packages/ + tray/ + package.json # Electron + electron-builder deps + tsconfig.json # TypeScript config (Node/ES2022) + jest.config.js # Jest config for ts-jest + electron-builder.yml # Platform build targets, autoLaunch, extraResources + src/ + main.ts # Electron entry — app lifecycle, tray creation, menu + notifications.ts # Show/update OS-native notifications + health.ts # Poll daemon health, emit state changes + daemon.ts # Spawn/stop/restart devbrain-daemon, PID management + bootstrap.ts # First-run: config, Ollama install, model pull + paths.ts # Resolve platform-specific paths (data dir, binaries, icons) + assets/ + icon.png # 256x256 tray icon (green state — base) + icon-yellow.png # Yellow state (starting/bootstrapping) + icon-red.png # Red state (stopped/error) + icon.ico # Windows .ico (multi-resolution) + icon.icns # macOS .icns + build/ + installer.nsh # NSIS script for PATH manipulation (Windows) + linux-after-install.sh # Post-install: symlinks + autostart (Linux .deb) + linux-after-remove.sh # Pre-remove: cleanup (Linux .deb) + __tests__/ + health.test.ts # Health state machine transitions + daemon.test.ts # Spawn/restart/crash logic + bootstrap.test.ts # Config creation, Ollama detection, idempotency + homebrew/ + devbrain.rb # Homebrew formula + winget/ + DevBrain.DevBrain.yaml # winget manifest (version template) + apt/ + debian/ + control # Package metadata + dependencies + postinst # Symlinks + autostart registration + prerm # Stop daemon + tray, remove autostart + rules # dpkg-buildpackage rules + devbrain.desktop # XDG autostart .desktop file +.github/ + workflows/ + package.yml # Electron build + package manager publish +``` + +### Modified Files + +``` +.gitignore # Add packages/tray/dist/, packages/tray/node_modules/ +src/DevBrain.Cli/Commands/StartCommand.cs # Add tray.lock check +src/DevBrain.Cli/Commands/StopCommand.cs # Add tray.lock check + stopped sentinel +``` + +--- + +## Task 1: Project Scaffolding — npm Workspaces + Electron Skeleton + +**Files:** +- Create: `package.json` (root) +- Create: `packages/tray/package.json` +- Create: `packages/tray/tsconfig.json` +- Modify: `.gitignore` + +- [ ] **Step 1: Create root package.json for npm workspaces** + +```json +{ + "name": "devbrain", + "private": true, + "workspaces": [ + "dashboard", + "packages/tray" + ] +} +``` + +Write to: `package.json` (project root) + +- [ ] **Step 2: Create packages/tray/package.json** + +```json +{ + "name": "devbrain-tray", + "version": "1.0.0", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "npm run build && electron dist/main.js", + "test": "jest --config jest.config.js", + "pack": "npm run build && electron-builder --dir", + "dist": "npm run build && electron-builder" + }, + "dependencies": { + "electron-log": "^5.3.0" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "electron": "^36.0.0", + "electron-builder": "^26.0.0", + "jest": "^30.0.0", + "@types/jest": "^30.0.0", + "ts-jest": "^29.3.0", + "typescript": "~6.0.2" + } +} +``` + +Write to: `packages/tray/package.json` + +- [ ] **Step 3: Create packages/tray/tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["__tests__", "dist", "node_modules"] +} +``` + +Write to: `packages/tray/tsconfig.json` + +- [ ] **Step 4: Update .gitignore** + +Add these lines to `.gitignore`: + +``` +packages/tray/dist/ +packages/tray/node_modules/ +packages/tray/resources/bin/ +packages/tray/resources/wwwroot/ +``` + +- [ ] **Step 5: Install dependencies** + +Run: `cd packages/tray && npm install` + +Expected: `node_modules/` created, no errors. + +- [ ] **Step 6: Verify workspace setup** + +Run from project root: `npm ls --workspaces` + +Expected: Lists `devbrain-dashboard` and `devbrain-tray` as workspaces. + +- [ ] **Step 7: Commit** + +```bash +git add package.json packages/tray/package.json packages/tray/tsconfig.json .gitignore +git commit -m "feat(packaging): scaffold Electron tray app with npm workspaces" +``` + +--- + +## Task 2: Platform Paths Module + +**Files:** +- Create: `packages/tray/src/paths.ts` + +This module resolves all platform-specific paths. Every other module imports from here — no hardcoded paths elsewhere. + +- [ ] **Step 1: Write paths.ts** + +```typescript +import * as path from "path"; +import * as os from "os"; +import { app } from "electron"; + +/** ~/.devbrain on all platforms */ +export function dataDir(): string { + return path.join(os.homedir(), ".devbrain"); +} + +/** ~/.devbrain/settings.toml */ +export function settingsPath(): string { + return path.join(dataDir(), "settings.toml"); +} + +/** ~/.devbrain/daemon.pid */ +export function pidPath(): string { + return path.join(dataDir(), "daemon.pid"); +} + +/** ~/.devbrain/tray.lock */ +export function trayLockPath(): string { + return path.join(dataDir(), "tray.lock"); +} + +/** ~/.devbrain/stopped — sentinel written by CLI to prevent tray auto-restart */ +export function stoppedSentinelPath(): string { + return path.join(dataDir(), "stopped"); +} + +/** ~/.devbrain/logs/ */ +export function logsDir(): string { + return path.join(dataDir(), "logs"); +} + +/** + * Resolve path to an embedded binary (devbrain-daemon or devbrain). + * In dev: looks in resources/bin/ relative to project. + * In packaged app: looks in resources/bin/ inside the asar/resources. + */ +export function binaryPath(name: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + const binaryName = `${name}${ext}`; + + if (app.isPackaged) { + return path.join(process.resourcesPath, "bin", binaryName); + } + + // Dev mode: expect binaries in resources/bin/ relative to project + return path.join(__dirname, "..", "resources", "bin", binaryName); +} + +/** + * Resolve tray icon path by state. + * @param state - "green" | "yellow" | "red" + */ +export function iconPath(state: "green" | "yellow" | "red"): string { + const suffix = state === "green" ? "" : `-${state}`; + const ext = process.platform === "win32" ? ".ico" : ".png"; + const filename = `icon${suffix}${ext}`; + + if (app.isPackaged) { + return path.join(process.resourcesPath, "assets", filename); + } + + return path.join(__dirname, "..", "assets", filename); +} +``` + +Write to: `packages/tray/src/paths.ts` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/tray && npx tsc --noEmit` + +Expected: No errors (Electron types resolve `app` import). + +- [ ] **Step 3: Commit** + +```bash +git add packages/tray/src/paths.ts +git commit -m "feat(packaging): add platform path resolution module" +``` + +--- + +## Task 3: Notifications Module + +**Files:** +- Create: `packages/tray/src/notifications.ts` + +Thin wrapper over Electron's `Notification` API. + +- [ ] **Step 1: Write notifications.ts** + +```typescript +import { Notification } from "electron"; + +const APP_NAME = "DevBrain"; + +export function showInfo(title: string, body: string): void { + new Notification({ title: `${APP_NAME}: ${title}`, body }).show(); +} + +export function showError(title: string, body: string): void { + new Notification({ + title: `${APP_NAME}: ${title}`, + body, + urgency: "critical", + }).show(); +} + +export function showProgress(title: string, body: string): Notification { + const n = new Notification({ title: `${APP_NAME}: ${title}`, body }); + n.show(); + return n; +} +``` + +Write to: `packages/tray/src/notifications.ts` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd packages/tray && npx tsc --noEmit` + +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/tray/src/notifications.ts +git commit -m "feat(packaging): add OS notification helpers" +``` + +--- + +## Task 4: Health Monitor — TDD + +**Files:** +- Create: `packages/tray/jest.config.js` +- Create: `packages/tray/__tests__/health.test.ts` +- Create: `packages/tray/src/health.ts` + +The health monitor polls the daemon's `/api/v1/health` endpoint and emits state transitions. + +- [ ] **Step 1: Create Jest config** + +```javascript +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/__tests__"], + testMatch: ["**/*.test.ts"], +}; +``` + +Write to: `packages/tray/jest.config.js` + +- [ ] **Step 2: Write the failing tests** + +```typescript +import { HealthMonitor, HealthState } from "../src/health"; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +describe("HealthMonitor", () => { + let monitor: HealthMonitor; + let states: HealthState[]; + + beforeEach(() => { + jest.useFakeTimers(); + states = []; + monitor = new HealthMonitor(1000); + monitor.on("stateChange", (s: HealthState) => states.push(s)); + mockFetch.mockReset(); + }); + + afterEach(() => { + monitor.stop(); + jest.useRealTimers(); + }); + + it("starts in 'starting' state", () => { + expect(monitor.state).toBe("starting"); + }); + + it("transitions to 'healthy' on successful health check", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + expect(monitor.state).toBe("healthy"); + expect(states).toEqual(["healthy"]); + }); + + it("transitions to 'unhealthy' on failed health check", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + expect(monitor.state).toBe("unhealthy"); + expect(states).toEqual(["unhealthy"]); + }); + + it("transitions healthy -> unhealthy -> healthy", async () => { + mockFetch + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + expect(states).toEqual(["healthy", "unhealthy", "healthy"]); + }); + + it("does not emit duplicate states", async () => { + mockFetch + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + expect(states).toEqual(["healthy"]); + }); + + it("stop() clears the polling interval", async () => { + mockFetch.mockResolvedValue({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + monitor.stop(); + mockFetch.mockReset(); + await jest.advanceTimersByTimeAsync(5000); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); +``` + +Write to: `packages/tray/__tests__/health.test.ts` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd packages/tray && npx jest` + +Expected: FAIL — `Cannot find module '../src/health'` + +- [ ] **Step 4: Implement health.ts** + +```typescript +import { EventEmitter } from "events"; + +export type HealthState = "starting" | "healthy" | "unhealthy"; + +const DAEMON_URL = "http://127.0.0.1:37800/api/v1/health"; + +export class HealthMonitor extends EventEmitter { + private _state: HealthState = "starting"; + private timer: ReturnType | null = null; + private pollIntervalMs: number; + + constructor(pollIntervalMs = 5000) { + super(); + this.pollIntervalMs = pollIntervalMs; + } + + get state(): HealthState { + return this._state; + } + + start(): void { + this.timer = setInterval(() => this.check(), this.pollIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async check(): Promise { + let newState: HealthState; + + try { + const res = await fetch(DAEMON_URL); + newState = res.ok ? "healthy" : "unhealthy"; + } catch { + newState = "unhealthy"; + } + + if (newState !== this._state) { + this._state = newState; + this.emit("stateChange", newState); + } + } +} +``` + +Write to: `packages/tray/src/health.ts` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd packages/tray && npx jest` + +Expected: 5 passing tests. + +- [ ] **Step 6: Commit** + +```bash +git add packages/tray/jest.config.js packages/tray/__tests__/health.test.ts packages/tray/src/health.ts +git commit -m "feat(packaging): add daemon health monitor with TDD" +``` + +--- + +## Task 5: Daemon Manager — TDD + +**Files:** +- Create: `packages/tray/__tests__/daemon.test.ts` +- Create: `packages/tray/src/daemon.ts` + +Manages spawning, stopping, and auto-restarting the daemon process. + +- [ ] **Step 1: Write the failing tests** + +```typescript +import { DaemonManager } from "../src/daemon"; +import * as child_process from "child_process"; +import * as fs from "fs"; + +jest.mock("child_process"); +jest.mock("fs"); + +const mockSpawn = child_process.spawn as jest.MockedFunction; +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; +const mockWriteFileSync = fs.writeFileSync as jest.MockedFunction; +const mockUnlinkSync = fs.unlinkSync as jest.MockedFunction; +const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +jest.mock("../src/paths", () => ({ + binaryPath: (name: string) => `/mock/bin/${name}`, + pidPath: () => "/mock/.devbrain/daemon.pid", + dataDir: () => "/mock/.devbrain", + stoppedSentinelPath: () => "/mock/.devbrain/stopped", + logsDir: () => "/mock/.devbrain/logs", +})); + +jest.mock("../src/notifications", () => ({ + showInfo: jest.fn(), + showError: jest.fn(), + showProgress: jest.fn(() => ({ close: jest.fn() })), +})); + +describe("DaemonManager", () => { + let daemon: DaemonManager; + let mockProcess: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + mockProcess = { + pid: 12345, + on: jest.fn(), + unref: jest.fn(), + }; + mockSpawn.mockReturnValue(mockProcess as child_process.ChildProcess); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReturnValue(undefined); + daemon = new DaemonManager(); + }); + + describe("start()", () => { + it("spawns devbrain-daemon as a detached process", async () => { + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockSpawn).toHaveBeenCalledWith( + "/mock/bin/devbrain-daemon", + [], + expect.objectContaining({ detached: true, stdio: "ignore" }) + ); + }); + + it("writes PID file after spawning", async () => { + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/mock/.devbrain/daemon.pid", + "12345" + ); + }); + + it("clears stopped sentinel before starting", async () => { + mockExistsSync.mockImplementation((p) => + String(p) === "/mock/.devbrain/stopped" + ); + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/stopped"); + }); + }); + + describe("stop()", () => { + it("kills process by PID from file", async () => { + mockExistsSync.mockImplementation((p) => + String(p) === "/mock/.devbrain/daemon.pid" + ); + mockReadFileSync.mockReturnValue("12345"); + const killMock = jest.fn(); + jest.spyOn(process, "kill").mockImplementation(killMock); + + await daemon.stop(); + + expect(killMock).toHaveBeenCalledWith(12345); + expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/daemon.pid"); + (process.kill as jest.Mock).mockRestore(); + }); + }); + + describe("auto-restart", () => { + it("starts with zero crash count", () => { + expect(daemon.crashCount).toBe(0); + }); + + it("stops restarting after 3 crashes", () => { + daemon.recordCrash(); + daemon.recordCrash(); + daemon.recordCrash(); + expect(daemon.shouldRestart()).toBe(false); + }); + + it("allows restart after manual reset", () => { + daemon.recordCrash(); + daemon.recordCrash(); + daemon.recordCrash(); + daemon.resetCrashCount(); + expect(daemon.shouldRestart()).toBe(true); + }); + }); +}); +``` + +Write to: `packages/tray/__tests__/daemon.test.ts` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/tray && npx jest daemon` + +Expected: FAIL — `Cannot find module '../src/daemon'` + +- [ ] **Step 3: Implement daemon.ts** + +```typescript +import { spawn, ChildProcess } from "child_process"; +import * as fs from "fs"; +import { + binaryPath, + pidPath, + dataDir, + stoppedSentinelPath, + logsDir, +} from "./paths"; +import { showError } from "./notifications"; + +const MAX_CRASHES = 3; +const CRASH_WINDOW_MS = 5 * 60 * 1000; + +export class DaemonManager { + private process: ChildProcess | null = null; + private crashes: number[] = []; + private onCrashCallback: (() => void) | null = null; + private onExhaustedCallback: (() => void) | null = null; + + get crashCount(): number { + return this.crashes.length; + } + + shouldRestart(): boolean { + const now = Date.now(); + this.crashes = this.crashes.filter((t) => now - t < CRASH_WINDOW_MS); + return this.crashes.length < MAX_CRASHES; + } + + recordCrash(): void { + this.crashes.push(Date.now()); + } + + resetCrashCount(): void { + this.crashes = []; + } + + onCrash(cb: () => void): void { + this.onCrashCallback = cb; + } + + onRestartsExhausted(cb: () => void): void { + this.onExhaustedCallback = cb; + } + + async start(): Promise { + const sentinel = stoppedSentinelPath(); + if (fs.existsSync(sentinel)) { + fs.unlinkSync(sentinel); + } + + const data = dataDir(); + if (!fs.existsSync(data)) { + fs.mkdirSync(data, { recursive: true }); + } + const logs = logsDir(); + if (!fs.existsSync(logs)) { + fs.mkdirSync(logs, { recursive: true }); + } + + const daemonBin = binaryPath("devbrain-daemon"); + + this.process = spawn(daemonBin, [], { + detached: true, + stdio: "ignore", + }); + + this.process.unref(); + + if (this.process.pid) { + fs.writeFileSync(pidPath(), String(this.process.pid)); + } + + this.process.on("exit", (code) => { + if (fs.existsSync(stoppedSentinelPath())) { + return; + } + + if (code !== 0 && code !== null) { + this.recordCrash(); + this.onCrashCallback?.(); + + if (this.shouldRestart()) { + this.start(); + } else { + showError( + "Daemon crashed", + "DevBrain daemon crashed 3 times in 5 minutes. Use the tray menu to restart." + ); + this.onExhaustedCallback?.(); + } + } + }); + } + + async stop(): Promise { + const pid = pidPath(); + + if (fs.existsSync(pid)) { + const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + + try { + process.kill(pidValue); + } catch { + // Process already dead + } + + fs.unlinkSync(pid); + } + + this.process = null; + } + + async restart(): Promise { + await this.stop(); + this.resetCrashCount(); + await this.start(); + } + + isRunning(): boolean { + const pid = pidPath(); + if (!fs.existsSync(pid)) return false; + + const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + + try { + process.kill(pidValue, 0); + return true; + } catch { + return false; + } + } +} +``` + +Write to: `packages/tray/src/daemon.ts` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/tray && npx jest daemon` + +Expected: All daemon tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/tray/__tests__/daemon.test.ts packages/tray/src/daemon.ts +git commit -m "feat(packaging): add daemon lifecycle manager with auto-restart" +``` + +--- + +## Task 6: Bootstrap Orchestrator — TDD + +**Files:** +- Create: `packages/tray/__tests__/bootstrap.test.ts` +- Create: `packages/tray/src/bootstrap.ts` + +First-run flow: config creation, Ollama detection, model pull. + +- [ ] **Step 1: Write the failing tests** + +```typescript +import { Bootstrap } from "../src/bootstrap"; +import * as fs from "fs"; +import * as child_process from "child_process"; + +jest.mock("fs"); +jest.mock("child_process"); + +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockWriteFileSync = fs.writeFileSync as jest.MockedFunction; +const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; +const mockExecFileSync = child_process.execFileSync as jest.MockedFunction; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +jest.mock("../src/paths", () => ({ + dataDir: () => "/mock/.devbrain", + settingsPath: () => "/mock/.devbrain/settings.toml", +})); + +jest.mock("../src/notifications", () => ({ + showInfo: jest.fn(), + showError: jest.fn(), + showProgress: jest.fn(() => ({ close: jest.fn() })), +})); + +describe("Bootstrap", () => { + let bootstrap: Bootstrap; + + beforeEach(() => { + jest.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReturnValue(undefined); + bootstrap = new Bootstrap(); + }); + + describe("ensureConfig()", () => { + it("creates settings.toml when missing", async () => { + mockExistsSync.mockReturnValue(false); + await bootstrap.ensureConfig(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/mock/.devbrain/settings.toml", + expect.stringContaining("[daemon]") + ); + }); + + it("skips config creation when file exists", async () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("settings.toml")); + await bootstrap.ensureConfig(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + describe("isOllamaInstalled()", () => { + it("returns true when Ollama API responds", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + const result = await bootstrap.isOllamaInstalled(); + expect(result).toBe(true); + }); + + it("returns false when Ollama API is unreachable", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + const result = await bootstrap.isOllamaInstalled(); + expect(result).toBe(false); + }); + }); + + describe("isModelPulled()", () => { + it("returns true when model is in ollama list output", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("llama3.2:3b\t3.2GB\n")); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(true); + }); + + it("returns false when model is not found", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("")); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(false); + }); + + it("returns false when ollama command fails", async () => { + mockExecFileSync.mockImplementation(() => { + throw new Error("command not found"); + }); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(false); + }); + }); + + describe("idempotency", () => { + it("running ensureConfig twice does not overwrite existing config", async () => { + mockExistsSync + .mockReturnValueOnce(false) // dataDir check + .mockReturnValueOnce(false) // settings check (first call) + .mockReturnValueOnce(true) // dataDir check + .mockReturnValueOnce(true); // settings check (second call) + + await bootstrap.ensureConfig(); + await bootstrap.ensureConfig(); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + }); + }); +}); +``` + +Write to: `packages/tray/__tests__/bootstrap.test.ts` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/tray && npx jest bootstrap` + +Expected: FAIL — `Cannot find module '../src/bootstrap'` + +- [ ] **Step 3: Implement bootstrap.ts** + +```typescript +import * as fs from "fs"; +import { execFileSync } from "child_process"; +import { dataDir, settingsPath } from "./paths"; +import { showInfo, showError, showProgress } from "./notifications"; + +const OLLAMA_API = "http://localhost:11434/api/version"; +const DEFAULT_MODEL = "llama3.2:3b"; + +const DEFAULT_SETTINGS = `[daemon] +port = 37800 +log_level = "info" + +[capture] +enabled = true +sources = ["ai-sessions"] +privacy_mode = "redact" +max_observation_size_kb = 512 +thread_gap_hours = 2 + +[storage] +sqlite_max_size_mb = 2048 +retention_days = 365 + +[llm.local] +enabled = true +provider = "ollama" +model = "llama3.2:3b" +endpoint = "http://localhost:11434" +max_concurrent = 2 + +[llm.cloud] +enabled = true +provider = "anthropic" +api_key_env = "DEVBRAIN_CLOUD_API_KEY" +max_daily_requests = 50 + +[agents.briefing] +enabled = true +schedule = "0 7 * * *" + +[agents.dead_end] +enabled = true +sensitivity = "medium" + +[agents.compression] +enabled = true +idle_minutes = 60 +`; + +export class Bootstrap { + async ensureConfig(): Promise { + const dir = dataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const settings = settingsPath(); + if (!fs.existsSync(settings)) { + fs.writeFileSync(settings, DEFAULT_SETTINGS); + } + } + + async isOllamaInstalled(): Promise { + try { + const res = await fetch(OLLAMA_API); + return res.ok; + } catch { + return false; + } + } + + async isModelPulled(model: string): Promise { + try { + const output = execFileSync("ollama", ["list"], { + encoding: "utf-8", + timeout: 10000, + }); + return output.includes(model); + } catch { + return false; + } + } + + async installOllama(): Promise { + showProgress("Setup", "Installing local AI runtime (first time only)..."); + + try { + if (process.platform === "win32") { + await this.installOllamaWindows(); + } else if (process.platform === "darwin") { + await this.installOllamaMac(); + } else { + await this.installOllamaLinux(); + } + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showError( + "Ollama install failed", + `Could not install Ollama: ${msg}. DevBrain works without it — add a cloud API key in Settings.` + ); + return false; + } + } + + async pullModel(model: string): Promise { + showProgress("Setup", "Downloading AI model (~2GB)..."); + + try { + execFileSync("ollama", ["pull", model], { + timeout: 600000, + stdio: "ignore", + }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showError("Model download failed", `Could not download ${model}: ${msg}`); + return false; + } + } + + /** + * Run the full bootstrap flow. Non-blocking — daemon starts first, + * Ollama install happens in background. + */ + async run(startDaemon: () => Promise): Promise { + await this.ensureConfig(); + await startDaemon(); + + // Ollama setup in background — non-blocking + this.bootstrapOllama().catch(() => { + // Errors already shown via notifications + }); + } + + private async bootstrapOllama(): Promise { + const installed = await this.isOllamaInstalled(); + if (!installed) { + const success = await this.installOllama(); + if (!success) return; + } + + const pulled = await this.isModelPulled(DEFAULT_MODEL); + if (!pulled) { + await this.pullModel(DEFAULT_MODEL); + } + + showInfo("Ready", "DevBrain is ready with local AI."); + } + + private async installOllamaWindows(): Promise { + const tmpPath = `${process.env.TEMP || "C:\\Temp"}\\OllamaSetup.exe`; + execFileSync("powershell", [ + "-Command", + `Invoke-WebRequest -Uri 'https://ollama.com/download/OllamaSetup.exe' -OutFile '${tmpPath}'`, + ], { timeout: 300000 }); + execFileSync(tmpPath, ["/S"], { timeout: 300000 }); + } + + private async installOllamaMac(): Promise { + try { + execFileSync("brew", ["install", "ollama"], { timeout: 300000 }); + } catch { + execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); + } + } + + private async installOllamaLinux(): Promise { + execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); + } +} +``` + +Write to: `packages/tray/src/bootstrap.ts` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/tray && npx jest bootstrap` + +Expected: All bootstrap tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/tray/__tests__/bootstrap.test.ts packages/tray/src/bootstrap.ts +git commit -m "feat(packaging): add first-run bootstrap orchestrator" +``` + +--- + +## Task 7: Electron Main Entry — Tray Icon + Context Menu + +**Files:** +- Create: `packages/tray/src/main.ts` +- Create: `packages/tray/assets/` (placeholder icons) + +This is the Electron entry point — creates the tray icon, wires up the context menu, and orchestrates health + daemon + bootstrap. + +- [ ] **Step 1: Create placeholder tray icon assets** + +Run: +```bash +mkdir -p packages/tray/assets +``` + +Create minimal placeholder files for each icon. These will be replaced with real designed icons before release. For now, copy the dashboard favicon or create empty files: + +```bash +echo "placeholder" > packages/tray/assets/icon.png +echo "placeholder" > packages/tray/assets/icon-yellow.png +echo "placeholder" > packages/tray/assets/icon-red.png +echo "placeholder" > packages/tray/assets/icon.ico +echo "placeholder" > packages/tray/assets/icon.icns +``` + +Note: Real icon assets (proper .ico, .icns, multi-resolution PNGs) must be designed and added before first release. electron-builder can generate .ico and .icns from a 1024x1024 PNG source. + +- [ ] **Step 2: Write main.ts** + +```typescript +import { app, Tray, Menu, shell, nativeImage } from "electron"; +import * as fs from "fs"; +import { HealthMonitor, HealthState } from "./health"; +import { DaemonManager } from "./daemon"; +import { Bootstrap } from "./bootstrap"; +import { iconPath, trayLockPath, dataDir, logsDir } from "./paths"; + +let tray: Tray | null = null; +let healthMonitor: HealthMonitor; +let daemonManager: DaemonManager; +let bootstrap: Bootstrap; +let currentState: HealthState = "starting"; + +function createTray(): void { + const icon = nativeImage.createFromPath(iconPath("green")); + tray = new Tray(icon); + tray.setToolTip("DevBrain (Starting...)"); + updateMenu(); +} + +function updateMenu(): void { + if (!tray) return; + + const statusLabel = + currentState === "healthy" + ? "DevBrain (Running)" + : currentState === "unhealthy" + ? "DevBrain (Stopped)" + : "DevBrain (Starting...)"; + + const template = Menu.buildFromTemplate([ + { label: statusLabel, enabled: false }, + { type: "separator" }, + { + label: "Open Dashboard", + click: () => shell.openExternal("http://localhost:37800"), + enabled: currentState === "healthy", + }, + { type: "separator" }, + { + label: "Start Daemon", + click: async () => { + await daemonManager.start(); + healthMonitor.start(); + }, + enabled: currentState !== "healthy", + }, + { + label: "Stop Daemon", + click: async () => { + await daemonManager.stop(); + }, + enabled: currentState === "healthy", + }, + { + label: "Restart Daemon", + click: async () => { + await daemonManager.restart(); + }, + }, + { type: "separator" }, + { + label: "View Logs", + click: () => { + const logs = logsDir(); + if (!fs.existsSync(logs)) { + fs.mkdirSync(logs, { recursive: true }); + } + shell.openPath(logs); + }, + }, + { type: "separator" }, + { + label: "Quit DevBrain", + click: async () => { + await daemonManager.stop(); + removeTrayLock(); + app.quit(); + }, + }, + ]); + + tray.setContextMenu(template); +} + +function updateTrayIcon(state: HealthState): void { + if (!tray) return; + + currentState = state; + + const iconState = + state === "healthy" ? "green" : state === "unhealthy" ? "red" : "yellow"; + + const icon = nativeImage.createFromPath(iconPath(iconState)); + tray.setImage(icon); + + const tooltip = + state === "healthy" + ? "DevBrain (Running)" + : state === "unhealthy" + ? "DevBrain (Stopped)" + : "DevBrain (Starting...)"; + tray.setToolTip(tooltip); + + updateMenu(); +} + +function writeTrayLock(): void { + const lockPath = trayLockPath(); + const dir = dataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(lockPath, String(process.pid)); +} + +function removeTrayLock(): void { + try { + fs.unlinkSync(trayLockPath()); + } catch { + // Best-effort + } +} + +app.whenReady().then(async () => { + const gotLock = app.requestSingleInstanceLock(); + if (!gotLock) { + app.quit(); + return; + } + + if (process.platform === "darwin") { + app.dock.hide(); + } + + writeTrayLock(); + + daemonManager = new DaemonManager(); + healthMonitor = new HealthMonitor(); + bootstrap = new Bootstrap(); + + createTray(); + + healthMonitor.on("stateChange", (state: HealthState) => { + updateTrayIcon(state); + }); + + daemonManager.onRestartsExhausted(() => { + updateTrayIcon("unhealthy"); + }); + + await bootstrap.run(() => daemonManager.start()); + + healthMonitor.start(); +}); + +app.on("window-all-closed", (e: Event) => { + e.preventDefault(); +}); + +app.on("before-quit", () => { + healthMonitor.stop(); + removeTrayLock(); +}); +``` + +Write to: `packages/tray/src/main.ts` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd packages/tray && npx tsc --noEmit` + +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/tray/src/main.ts packages/tray/assets/ +git commit -m "feat(packaging): add Electron main entry with tray icon and context menu" +``` + +--- + +## Task 8: CLI / Tray Coordination + +**Files:** +- Modify: `src/DevBrain.Cli/Commands/StartCommand.cs` +- Modify: `src/DevBrain.Cli/Commands/StopCommand.cs` + +Add `tray.lock` checks so CLI and tray app don't fight over daemon management. + +- [ ] **Step 1: Update StartCommand to check for tray.lock** + +In `src/DevBrain.Cli/Commands/StartCommand.cs`, replace the `Execute` method body. After the existing health check on line 18, add tray lock detection before spawning the daemon: + +```csharp +private static async Task Execute(ParseResult pr) +{ + var client = new DevBrainHttpClient(); + + if (await client.IsHealthy()) + { + ConsoleFormatter.PrintWarning("Daemon is already running."); + return; + } + + // Check if tray app is managing the daemon + var dataPath = SettingsLoader.ResolveDataPath("~/.devbrain"); + var trayLockPath = Path.Combine(dataPath, "tray.lock"); + + if (File.Exists(trayLockPath)) + { + var lockPidText = (await File.ReadAllTextAsync(trayLockPath)).Trim(); + if (int.TryParse(lockPidText, out var trayPid)) + { + try + { + Process.GetProcessById(trayPid); + ConsoleFormatter.PrintWarning( + "Daemon is managed by the tray app. Use the tray menu to start it."); + return; + } + catch (ArgumentException) + { + // Tray process is dead — stale lock, continue normally + } + } + } + + var cliDir = AppContext.BaseDirectory; + var daemonName = OperatingSystem.IsWindows() ? "devbrain-daemon.exe" : "devbrain-daemon"; + var daemonPath = Path.Combine(cliDir, daemonName); + + if (!File.Exists(daemonPath)) + { + ConsoleFormatter.PrintError($"Daemon binary not found at: {daemonPath}"); + return; + } + + var psi = new ProcessStartInfo + { + FileName = daemonPath, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + try + { + Process.Start(psi); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to start daemon: {ex.Message}"); + return; + } + + Console.Write("Starting daemon"); + + for (var i = 0; i < 20; i++) + { + await Task.Delay(500); + Console.Write("."); + + if (await client.IsHealthy()) + { + Console.WriteLine(); + ConsoleFormatter.PrintSuccess("Daemon started successfully."); + return; + } + } + + Console.WriteLine(); + ConsoleFormatter.PrintError("Daemon did not become healthy within 10 seconds."); +} +``` + +Note: Ensure `using DevBrain.Core;` is present at the top for `SettingsLoader`. + +- [ ] **Step 2: Update StopCommand to write stopped sentinel when tray is active** + +In `src/DevBrain.Cli/Commands/StopCommand.cs`, replace the `Execute` method body. Before killing the daemon process, check for the tray lock and write a sentinel: + +```csharp +private static async Task Execute(ParseResult pr) +{ + var dataPath = SettingsLoader.ResolveDataPath("~/.devbrain"); + var pidPath = Path.Combine(dataPath, "daemon.pid"); + + if (!File.Exists(pidPath)) + { + ConsoleFormatter.PrintWarning("No PID file found. Daemon may not be running."); + return; + } + + // If tray app is running, write stopped sentinel so it doesn't auto-restart + var trayLockPath = Path.Combine(dataPath, "tray.lock"); + if (File.Exists(trayLockPath)) + { + var trayPidText = (await File.ReadAllTextAsync(trayLockPath)).Trim(); + if (int.TryParse(trayPidText, out var trayPid)) + { + try + { + Process.GetProcessById(trayPid); + // Tray is alive — write sentinel to prevent auto-restart + var sentinelPath = Path.Combine(dataPath, "stopped"); + await File.WriteAllTextAsync(sentinelPath, "stopped by cli"); + } + catch (ArgumentException) + { + // Tray is dead — no sentinel needed + } + } + } + + var pidText = (await File.ReadAllTextAsync(pidPath)).Trim(); + + if (!int.TryParse(pidText, out var pid)) + { + ConsoleFormatter.PrintError($"Invalid PID file content: {pidText}"); + return; + } + + try + { + var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + process.WaitForExit(5000); + ConsoleFormatter.PrintSuccess($"Daemon (PID {pid}) stopped."); + } + catch (ArgumentException) + { + ConsoleFormatter.PrintWarning($"No process found with PID {pid}. Daemon may have already stopped."); + } + catch (Exception ex) + { + ConsoleFormatter.PrintError($"Failed to stop daemon: {ex.Message}"); + } + + try + { + File.Delete(pidPath); + } + catch + { + // Best-effort cleanup + } +} +``` + +- [ ] **Step 3: Verify .NET build** + +Run: `dotnet build DevBrain.slnx` + +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Run existing tests** + +Run: `dotnet test DevBrain.slnx` + +Expected: All 54+ tests pass (no behavioral changes to existing functionality). + +- [ ] **Step 5: Commit** + +```bash +git add src/DevBrain.Cli/Commands/StartCommand.cs src/DevBrain.Cli/Commands/StopCommand.cs +git commit -m "feat(packaging): add CLI/tray coordination via tray.lock + stopped sentinel" +``` + +--- + +## Task 9: electron-builder Configuration + +**Files:** +- Create: `packages/tray/electron-builder.yml` +- Create: `packages/tray/build/installer.nsh` +- Create: `packages/tray/build/linux-after-install.sh` +- Create: `packages/tray/build/linux-after-remove.sh` + +Configures electron-builder to produce NSIS (Windows), DMG (macOS), and .deb (Linux). + +- [ ] **Step 1: Write electron-builder.yml** + +```yaml +appId: com.devbrain.tray +productName: DevBrain +copyright: Copyright 2026 DevBrain + +extraResources: + - from: resources/bin/ + to: bin/ + filter: + - "**/*" + - from: resources/wwwroot/ + to: wwwroot/ + filter: + - "**/*" + - from: assets/ + to: assets/ + filter: + - "*.png" + - "*.ico" + - "*.icns" + +win: + target: + - target: nsis + arch: [x64] + icon: assets/icon.ico + +nsis: + oneClick: true + allowToChangeInstallationDirectory: false + perMachine: false + installerIcon: assets/icon.ico + include: build/installer.nsh + +mac: + target: + - target: dmg + arch: [x64] + icon: assets/icon.icns + category: public.app-category.developer-tools + +dmg: + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications + +linux: + target: + - target: deb + arch: [x64] + - target: AppImage + arch: [x64] + icon: assets/icon.png + category: Development + desktop: + StartupWMClass: DevBrain + +deb: + depends: + - libgtk-3-0 + - libnotify4 + - libnss3 + afterInstall: build/linux-after-install.sh + afterRemove: build/linux-after-remove.sh +``` + +Write to: `packages/tray/electron-builder.yml` + +- [ ] **Step 2: Create NSIS installer script for PATH** + +```nsis +!macro customInstall + nsExec::ExecToLog 'setx PATH "%PATH%;$INSTDIR\resources\bin"' +!macroend + +!macro customUnInstall + ; PATH cleanup is complex in NSIS — users can manually clean up +!macroend +``` + +Write to: `packages/tray/build/installer.nsh` + +- [ ] **Step 3: Create Linux post-install script** + +```bash +#!/bin/bash +set -e + +ln -sf /opt/DevBrain/resources/bin/devbrain /usr/local/bin/devbrain +ln -sf /opt/DevBrain/resources/bin/devbrain-daemon /usr/local/bin/devbrain-daemon + +mkdir -p /etc/xdg/autostart +cat > /etc/xdg/autostart/devbrain.desktop << 'EOF' +[Desktop Entry] +Type=Application +Name=DevBrain +Exec=/opt/DevBrain/devbrain-tray +Icon=/opt/DevBrain/resources/assets/icon.png +Comment=Developer's second brain +Categories=Development; +X-GNOME-Autostart-enabled=true +StartupNotify=false +Terminal=false +EOF +``` + +Write to: `packages/tray/build/linux-after-install.sh` + +- [ ] **Step 4: Create Linux post-remove script** + +```bash +#!/bin/bash +set -e + +rm -f /usr/local/bin/devbrain +rm -f /usr/local/bin/devbrain-daemon +rm -f /etc/xdg/autostart/devbrain.desktop + +pkill -f devbrain-daemon 2>/dev/null || true +pkill -f devbrain-tray 2>/dev/null || true + +# NOTE: ~/.devbrain/ is intentionally preserved (user data) +``` + +Write to: `packages/tray/build/linux-after-remove.sh` + +- [ ] **Step 5: Make Linux scripts executable** + +Run: `chmod +x packages/tray/build/linux-after-install.sh packages/tray/build/linux-after-remove.sh` + +- [ ] **Step 6: Commit** + +```bash +git add packages/tray/electron-builder.yml packages/tray/build/ +git commit -m "feat(packaging): add electron-builder config for Windows/macOS/Linux" +``` + +--- + +## Task 10: Package Manager Manifests + +**Files:** +- Create: `packages/homebrew/devbrain.rb` +- Create: `packages/winget/DevBrain.DevBrain.yaml` +- Create: `packages/apt/debian/control` +- Create: `packages/apt/debian/postinst` +- Create: `packages/apt/debian/prerm` +- Create: `packages/apt/debian/rules` +- Create: `packages/apt/debian/devbrain.desktop` + +- [ ] **Step 1: Create Homebrew formula** + +```ruby +class Devbrain < Formula + desc "Developer's second brain - captures coding sessions, builds knowledge graph" + homepage "https://github.com/devbrain/devbrain" + version "1.0.0" + + if OS.mac? && Hardware::CPU.arm? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-osx-arm64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.mac? && Hardware::CPU.intel? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-osx-x64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.linux? && Hardware::CPU.intel? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-linux-x64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.linux? && Hardware::CPU.arm? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-linux-arm64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + end + + def install + bin.install "devbrain" + bin.install "devbrain-daemon" + prefix.install "DevBrain.app" if OS.mac? + end + + # No Homebrew service block - the Electron tray app owns daemon lifecycle. + + def post_install + # Tray app handles all user-space bootstrap on first launch. + end + + test do + assert_match "devbrain", shell_output("#{bin}/devbrain --version") + end +end +``` + +Write to: `packages/homebrew/devbrain.rb` + +- [ ] **Step 2: Create winget manifest** + +```yaml +PackageIdentifier: DevBrain.DevBrain +PackageVersion: 1.0.0 +DefaultLocale: en-US +PackageName: DevBrain +Publisher: DevBrain +PublisherUrl: https://github.com/devbrain/devbrain +License: Apache-2.0 +ShortDescription: Developer's second brain - captures coding sessions, builds knowledge graph +Tags: + - developer-tools + - productivity + - knowledge-graph + - ai +InstallerType: exe +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/devbrain/devbrain/releases/download/v1.0.0/DevBrain-Setup-1.0.0-x64.exe + InstallerSha256: PLACEHOLDER_SHA256 + InstallerSwitches: + Silent: /S + SilentWithProgress: /S +ManifestType: singleton +ManifestVersion: 1.6.0 +``` + +Write to: `packages/winget/DevBrain.DevBrain.yaml` + +- [ ] **Step 3: Create APT debian/control** + +``` +Package: devbrain +Version: 1.0.0 +Section: devel +Priority: optional +Architecture: amd64 +Depends: libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, libatspi2.0-0 +Maintainer: DevBrain +Homepage: https://github.com/devbrain/devbrain +Description: Developer's second brain + DevBrain is a background daemon that passively captures AI coding sessions, + builds a knowledge graph of decisions and dead ends, and surfaces proactive + insights including morning briefings, pattern detection, and semantic search. +``` + +Write to: `packages/apt/debian/control` + +- [ ] **Step 4: Create APT postinst** + +```bash +#!/bin/bash +set -e + +ln -sf /opt/devbrain/resources/bin/devbrain /usr/local/bin/devbrain +ln -sf /opt/devbrain/resources/bin/devbrain-daemon /usr/local/bin/devbrain-daemon + +mkdir -p /etc/xdg/autostart +cp /opt/devbrain/devbrain.desktop /etc/xdg/autostart/devbrain.desktop 2>/dev/null || true +``` + +Write to: `packages/apt/debian/postinst` + +- [ ] **Step 5: Create APT prerm** + +```bash +#!/bin/bash +set -e + +pkill -f devbrain-daemon 2>/dev/null || true +pkill -f devbrain-tray 2>/dev/null || true + +rm -f /usr/local/bin/devbrain +rm -f /usr/local/bin/devbrain-daemon +rm -f /etc/xdg/autostart/devbrain.desktop + +# ~/.devbrain/ is intentionally preserved (user data) +``` + +Write to: `packages/apt/debian/prerm` + +- [ ] **Step 6: Create APT rules** + +```makefile +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + true + +override_dh_auto_install: + mkdir -p debian/devbrain/opt/devbrain + cp -r . debian/devbrain/opt/devbrain/ +``` + +Write to: `packages/apt/debian/rules` + +- [ ] **Step 7: Create desktop file** + +```desktop +[Desktop Entry] +Type=Application +Name=DevBrain +Exec=/opt/devbrain/devbrain-tray +Icon=/opt/devbrain/resources/assets/icon.png +Comment=Developer's second brain +Categories=Development; +X-GNOME-Autostart-enabled=true +StartupNotify=false +Terminal=false +``` + +Write to: `packages/apt/debian/devbrain.desktop` + +- [ ] **Step 8: Make scripts executable** + +Run: `chmod +x packages/apt/debian/postinst packages/apt/debian/prerm packages/apt/debian/rules` + +- [ ] **Step 9: Commit** + +```bash +git add packages/homebrew/ packages/winget/ packages/apt/ +git commit -m "feat(packaging): add Homebrew, winget, and APT package manifests" +``` + +--- + +## Task 11: CI/CD — Electron Build + Package Publish Workflow + +**Files:** +- Create: `.github/workflows/package.yml` + +- [ ] **Step 1: Write package.yml** + +```yaml +name: Package & Publish + +on: + workflow_run: + workflows: ["Build & Test"] + types: [completed] + branches: [main, master] + +jobs: + check: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + outputs: + is_tag: ${{ steps.check_tag.outputs.is_tag }} + steps: + - id: check_tag + run: | + if [[ "${{ github.event.workflow_run.head_branch }}" == v* ]]; then + echo "is_tag=true" >> $GITHUB_OUTPUT + else + echo "is_tag=false" >> $GITHUB_OUTPUT + fi + + electron-build: + needs: check + strategy: + matrix: + include: + - os: windows-latest + rid: win-x64 + electron-args: --win --x64 + artifact-pattern: "*.exe" + - os: macos-latest + rid: osx-x64 + electron-args: --mac --x64 + artifact-pattern: "*.dmg" + - os: ubuntu-latest + rid: linux-x64 + electron-args: --linux --x64 + artifact-pattern: "*.deb" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/download-artifact@v4 + with: + name: devbrain-${{ matrix.rid }} + path: packages/tray/resources/bin/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/download-artifact@v4 + with: + name: dashboard-dist + path: packages/tray/resources/wwwroot/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract .NET binaries (Unix) + if: runner.os != 'Windows' + run: | + cd packages/tray/resources/bin + for f in *.tar.gz; do [ -f "$f" ] && tar xzf "$f" && rm "$f"; done + chmod +x devbrain devbrain-daemon 2>/dev/null || true + + - name: Extract .NET binaries (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cd packages/tray/resources/bin + for f in *.zip; do [ -f "$f" ] && 7z x "$f" -y && rm "$f"; done + + - name: Install dependencies + run: cd packages/tray && npm ci + + - name: Build TypeScript + run: cd packages/tray && npm run build + + - name: Build Electron package + run: cd packages/tray && npx electron-builder ${{ matrix.electron-args }} --publish never + + - uses: actions/upload-artifact@v4 + with: + name: electron-${{ matrix.rid }} + path: packages/tray/dist/${{ matrix.artifact-pattern }} + + publish-homebrew: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update Homebrew formula + uses: Justintime50/homebrew-releaser@v1 + with: + homebrew_owner: devbrain + homebrew_tap: homebrew-tap + formula_folder: Formula + github_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + commit_owner: devbrain-bot + commit_email: bot@devbrain.dev + install: | + bin.install "devbrain" + bin.install "devbrain-daemon" + test: | + assert_match "devbrain", shell_output("#{bin}/devbrain --version") + + publish-winget: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: windows-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: electron-win-x64 + path: installer/ + + - name: Submit to winget + uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: DevBrain.DevBrain + installers-regex: '\.exe$' + token: ${{ secrets.WINGET_TOKEN }} + + publish-apt: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: electron-linux-x64 + path: deb-package/ + + - name: Setup GPG + run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import + + - name: Update APT repository + run: | + mkdir -p apt-repo/pool/main + cp deb-package/*.deb apt-repo/pool/main/ + cd apt-repo + dpkg-scanpackages pool/main /dev/null | gzip -9c > Packages.gz + apt-ftparchive release . > Release + gpg --batch --yes --armor --detach-sign -o Release.gpg Release + gpg --batch --yes --clearsign -o InRelease Release + + - name: Publish to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./apt-repo + destination_dir: apt +``` + +Write to: `.github/workflows/package.yml` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/package.yml +git commit -m "feat(packaging): add CI/CD for Electron build + package manager publishing" +``` + +--- + +## Task 12: Local Build Verification + +Verify everything builds and tests pass locally. + +- [ ] **Step 1: Run all tray app tests** + +Run: `cd packages/tray && npx jest --verbose` + +Expected: All tests pass (~15 tests across health, daemon, bootstrap). + +- [ ] **Step 2: Build TypeScript** + +Run: `cd packages/tray && npm run build` + +Expected: `dist/` directory created with compiled `.js` files, no errors. + +- [ ] **Step 3: Verify .NET build still passes** + +Run: `dotnet build DevBrain.slnx` + +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Run all .NET tests** + +Run: `dotnet test DevBrain.slnx` + +Expected: All tests pass. + +- [ ] **Step 5: Test local Electron build (optional — requires pre-built .NET binaries)** + +To test the full Electron build locally, first populate the resources: + +```bash +mkdir -p packages/tray/resources/bin packages/tray/resources/wwwroot +dotnet publish src/DevBrain.Api -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o packages/tray/resources/bin/ +dotnet publish src/DevBrain.Cli -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o packages/tray/resources/bin/ +# Rename to clean names +mv packages/tray/resources/bin/DevBrain.Api.exe packages/tray/resources/bin/devbrain-daemon.exe 2>/dev/null || true +mv packages/tray/resources/bin/DevBrain.Cli.exe packages/tray/resources/bin/devbrain.exe 2>/dev/null || true +# Copy dashboard +cp -r dashboard/dist/* packages/tray/resources/wwwroot/ 2>/dev/null || true +``` + +Then build unpacked: +```bash +cd packages/tray && npx electron-builder --dir +``` + +Expected: `packages/tray/dist/` contains an unpacked Electron app. + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "feat(packaging): complete single-click packaging implementation" +``` + +--- + +## Summary + +| Task | What it builds | New files | Tests | +|------|---------------|-----------|-------| +| 1 | npm workspaces + Electron scaffold | 3 + modify 1 | — | +| 2 | Platform paths module | 1 | — | +| 3 | Notifications module | 1 | — | +| 4 | Health monitor (TDD) | 2 + jest config | 5 tests | +| 5 | Daemon manager (TDD) | 2 | 5 tests | +| 6 | Bootstrap orchestrator (TDD) | 2 | 5 tests | +| 7 | Electron main entry + tray | 1 + 5 assets | — | +| 8 | CLI/tray coordination | modify 2 | existing .NET tests | +| 9 | electron-builder config | 4 | — | +| 10 | Package manager manifests | 7 | — | +| 11 | CI/CD workflow | 1 | — | +| 12 | Local build verification | — | all tests | + +**Total:** ~30 new files, 2 modified files, ~15 new tests, 12 commits. From 3d6d45f76c9889bc1836942756a25ed8c4fcbe4d Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:12:08 -0700 Subject: [PATCH 24/36] feat(packaging): scaffold Electron tray app with npm workspaces Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 ++++ package.json | 8 ++++++++ packages/tray/package.json | 25 +++++++++++++++++++++++++ packages/tray/tsconfig.json | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 package.json create mode 100644 packages/tray/package.json create mode 100644 packages/tray/tsconfig.json diff --git a/.gitignore b/.gitignore index 2f13c26..bff9870 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ wwwroot/ .env.local *.pem docs/superpowers/ +packages/tray/dist/ +packages/tray/node_modules/ +packages/tray/resources/bin/ +packages/tray/resources/wwwroot/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..eafb200 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "devbrain", + "private": true, + "workspaces": [ + "dashboard", + "packages/tray" + ] +} diff --git a/packages/tray/package.json b/packages/tray/package.json new file mode 100644 index 0000000..290a1a1 --- /dev/null +++ b/packages/tray/package.json @@ -0,0 +1,25 @@ +{ + "name": "devbrain-tray", + "version": "1.0.0", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "npm run build && electron dist/main.js", + "test": "jest --config jest.config.js", + "pack": "npm run build && electron-builder --dir", + "dist": "npm run build && electron-builder" + }, + "dependencies": { + "electron-log": "^5.3.0" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "electron": "^36.0.0", + "electron-builder": "^26.0.0", + "jest": "^30.0.0", + "@types/jest": "^30.0.0", + "ts-jest": "^29.3.0", + "typescript": "~6.0.2" + } +} diff --git a/packages/tray/tsconfig.json b/packages/tray/tsconfig.json new file mode 100644 index 0000000..4813f48 --- /dev/null +++ b/packages/tray/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["__tests__", "dist", "node_modules"] +} From ac88bd8d4b4e3962da36bef899224e6acf683bf9 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:13:06 -0700 Subject: [PATCH 25/36] feat(packaging): add platform path resolution module Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/src/paths.ts | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/tray/src/paths.ts diff --git a/packages/tray/src/paths.ts b/packages/tray/src/paths.ts new file mode 100644 index 0000000..c354066 --- /dev/null +++ b/packages/tray/src/paths.ts @@ -0,0 +1,65 @@ +import * as path from "path"; +import * as os from "os"; +import { app } from "electron"; + +/** ~/.devbrain on all platforms */ +export function dataDir(): string { + return path.join(os.homedir(), ".devbrain"); +} + +/** ~/.devbrain/settings.toml */ +export function settingsPath(): string { + return path.join(dataDir(), "settings.toml"); +} + +/** ~/.devbrain/daemon.pid */ +export function pidPath(): string { + return path.join(dataDir(), "daemon.pid"); +} + +/** ~/.devbrain/tray.lock */ +export function trayLockPath(): string { + return path.join(dataDir(), "tray.lock"); +} + +/** ~/.devbrain/stopped — sentinel written by CLI to prevent tray auto-restart */ +export function stoppedSentinelPath(): string { + return path.join(dataDir(), "stopped"); +} + +/** ~/.devbrain/logs/ */ +export function logsDir(): string { + return path.join(dataDir(), "logs"); +} + +/** + * Resolve path to an embedded binary (devbrain-daemon or devbrain). + * In dev: looks in resources/bin/ relative to project. + * In packaged app: looks in resources/bin/ inside the asar/resources. + */ +export function binaryPath(name: string): string { + const ext = process.platform === "win32" ? ".exe" : ""; + const binaryName = `${name}${ext}`; + + if (app.isPackaged) { + return path.join(process.resourcesPath, "bin", binaryName); + } + + return path.join(__dirname, "..", "resources", "bin", binaryName); +} + +/** + * Resolve tray icon path by state. + * @param state - "green" | "yellow" | "red" + */ +export function iconPath(state: "green" | "yellow" | "red"): string { + const suffix = state === "green" ? "" : `-${state}`; + const ext = process.platform === "win32" ? ".ico" : ".png"; + const filename = `icon${suffix}${ext}`; + + if (app.isPackaged) { + return path.join(process.resourcesPath, "assets", filename); + } + + return path.join(__dirname, "..", "assets", filename); +} From db330b3e75598d97f5e6434a1dc48e2843b8c3eb Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:13:54 -0700 Subject: [PATCH 26/36] feat(packaging): add OS notification helpers Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/src/notifications.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/tray/src/notifications.ts diff --git a/packages/tray/src/notifications.ts b/packages/tray/src/notifications.ts new file mode 100644 index 0000000..9288e3a --- /dev/null +++ b/packages/tray/src/notifications.ts @@ -0,0 +1,21 @@ +import { Notification } from "electron"; + +const APP_NAME = "DevBrain"; + +export function showInfo(title: string, body: string): void { + new Notification({ title: `${APP_NAME}: ${title}`, body }).show(); +} + +export function showError(title: string, body: string): void { + new Notification({ + title: `${APP_NAME}: ${title}`, + body, + urgency: "critical", + }).show(); +} + +export function showProgress(title: string, body: string): Notification { + const n = new Notification({ title: `${APP_NAME}: ${title}`, body }); + n.show(); + return n; +} From 36c19b8409088b32098c5b142d54af894e1148b4 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:16:06 -0700 Subject: [PATCH 27/36] feat(packaging): add daemon health monitor with TDD Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/__tests__/health.test.ts | 74 ++++++++++++++++++++++++++ packages/tray/jest.config.js | 19 +++++++ packages/tray/src/health.ts | 47 ++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 packages/tray/__tests__/health.test.ts create mode 100644 packages/tray/jest.config.js create mode 100644 packages/tray/src/health.ts diff --git a/packages/tray/__tests__/health.test.ts b/packages/tray/__tests__/health.test.ts new file mode 100644 index 0000000..814b5f3 --- /dev/null +++ b/packages/tray/__tests__/health.test.ts @@ -0,0 +1,74 @@ +import { HealthMonitor, HealthState } from "../src/health"; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +describe("HealthMonitor", () => { + let monitor: HealthMonitor; + let states: HealthState[]; + + beforeEach(() => { + jest.useFakeTimers(); + states = []; + monitor = new HealthMonitor(1000); + monitor.on("stateChange", (s: HealthState) => states.push(s)); + mockFetch.mockReset(); + }); + + afterEach(() => { + monitor.stop(); + jest.useRealTimers(); + }); + + it("starts in 'starting' state", () => { + expect(monitor.state).toBe("starting"); + }); + + it("transitions to 'healthy' on successful health check", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + expect(monitor.state).toBe("healthy"); + expect(states).toEqual(["healthy"]); + }); + + it("transitions to 'unhealthy' on failed health check", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + expect(monitor.state).toBe("unhealthy"); + expect(states).toEqual(["unhealthy"]); + }); + + it("transitions healthy -> unhealthy -> healthy", async () => { + mockFetch + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + expect(states).toEqual(["healthy", "unhealthy", "healthy"]); + }); + + it("does not emit duplicate states", async () => { + mockFetch + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + expect(states).toEqual(["healthy"]); + }); + + it("stop() clears the polling interval", async () => { + mockFetch.mockResolvedValue({ ok: true }); + monitor.start(); + await jest.advanceTimersByTimeAsync(1000); + monitor.stop(); + mockFetch.mockReset(); + await jest.advanceTimersByTimeAsync(5000); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tray/jest.config.js b/packages/tray/jest.config.js new file mode 100644 index 0000000..d568ad9 --- /dev/null +++ b/packages/tray/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/__tests__"], + testMatch: ["**/*.test.ts"], + transform: { + "^.+\\.ts$": ["ts-jest", { + tsconfig: { + target: "ES2022", + module: "commonjs", + lib: ["ES2022"], + strict: true, + esModuleInterop: true, + skipLibCheck: true, + types: ["jest", "node"], + }, + }], + }, +}; diff --git a/packages/tray/src/health.ts b/packages/tray/src/health.ts new file mode 100644 index 0000000..fecd427 --- /dev/null +++ b/packages/tray/src/health.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from "events"; + +export type HealthState = "starting" | "healthy" | "unhealthy"; + +const DAEMON_URL = "http://127.0.0.1:37800/api/v1/health"; + +export class HealthMonitor extends EventEmitter { + private _state: HealthState = "starting"; + private timer: ReturnType | null = null; + private pollIntervalMs: number; + + constructor(pollIntervalMs = 5000) { + super(); + this.pollIntervalMs = pollIntervalMs; + } + + get state(): HealthState { + return this._state; + } + + start(): void { + this.timer = setInterval(() => this.check(), this.pollIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async check(): Promise { + let newState: HealthState; + + try { + const res = await fetch(DAEMON_URL); + newState = res.ok ? "healthy" : "unhealthy"; + } catch { + newState = "unhealthy"; + } + + if (newState !== this._state) { + this._state = newState; + this.emit("stateChange", newState); + } + } +} From 7ef79c9ea3ff0b7ac97dc165293eedcd67a7dc9b Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:17:39 -0700 Subject: [PATCH 28/36] feat(packaging): add daemon lifecycle manager with auto-restart Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/__tests__/daemon.test.ts | 116 +++++++++++++++++++++ packages/tray/src/daemon.ts | 134 +++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 packages/tray/__tests__/daemon.test.ts create mode 100644 packages/tray/src/daemon.ts diff --git a/packages/tray/__tests__/daemon.test.ts b/packages/tray/__tests__/daemon.test.ts new file mode 100644 index 0000000..2e3687c --- /dev/null +++ b/packages/tray/__tests__/daemon.test.ts @@ -0,0 +1,116 @@ +import { DaemonManager } from "../src/daemon"; +import * as child_process from "child_process"; +import * as fs from "fs"; + +jest.mock("child_process"); +jest.mock("fs"); + +const mockSpawn = child_process.spawn as jest.MockedFunction; +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; +const mockWriteFileSync = fs.writeFileSync as jest.MockedFunction; +const mockUnlinkSync = fs.unlinkSync as jest.MockedFunction; +const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +jest.mock("../src/paths", () => ({ + binaryPath: (name: string) => `/mock/bin/${name}`, + pidPath: () => "/mock/.devbrain/daemon.pid", + dataDir: () => "/mock/.devbrain", + stoppedSentinelPath: () => "/mock/.devbrain/stopped", + logsDir: () => "/mock/.devbrain/logs", +})); + +jest.mock("../src/notifications", () => ({ + showInfo: jest.fn(), + showError: jest.fn(), + showProgress: jest.fn(() => ({ close: jest.fn() })), +})); + +describe("DaemonManager", () => { + let daemon: DaemonManager; + let mockProcess: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + mockProcess = { + pid: 12345, + on: jest.fn(), + unref: jest.fn(), + }; + mockSpawn.mockReturnValue(mockProcess as child_process.ChildProcess); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReturnValue(undefined); + daemon = new DaemonManager(); + }); + + describe("start()", () => { + it("spawns devbrain-daemon as a detached process", async () => { + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockSpawn).toHaveBeenCalledWith( + "/mock/bin/devbrain-daemon", + [], + expect.objectContaining({ detached: true, stdio: "ignore" }) + ); + }); + + it("writes PID file after spawning", async () => { + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/mock/.devbrain/daemon.pid", + "12345" + ); + }); + + it("clears stopped sentinel before starting", async () => { + mockExistsSync.mockImplementation((p) => + String(p) === "/mock/.devbrain/stopped" + ); + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/stopped"); + }); + }); + + describe("stop()", () => { + it("kills process by PID from file", async () => { + mockExistsSync.mockImplementation((p) => + String(p) === "/mock/.devbrain/daemon.pid" + ); + mockReadFileSync.mockReturnValue("12345"); + const killMock = jest.fn(); + jest.spyOn(process, "kill").mockImplementation(killMock); + + await daemon.stop(); + + expect(killMock).toHaveBeenCalledWith(12345); + expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/daemon.pid"); + (process.kill as jest.Mock).mockRestore(); + }); + }); + + describe("auto-restart", () => { + it("starts with zero crash count", () => { + expect(daemon.crashCount).toBe(0); + }); + + it("stops restarting after 3 crashes", () => { + daemon.recordCrash(); + daemon.recordCrash(); + daemon.recordCrash(); + expect(daemon.shouldRestart()).toBe(false); + }); + + it("allows restart after manual reset", () => { + daemon.recordCrash(); + daemon.recordCrash(); + daemon.recordCrash(); + daemon.resetCrashCount(); + expect(daemon.shouldRestart()).toBe(true); + }); + }); +}); diff --git a/packages/tray/src/daemon.ts b/packages/tray/src/daemon.ts new file mode 100644 index 0000000..54d501a --- /dev/null +++ b/packages/tray/src/daemon.ts @@ -0,0 +1,134 @@ +import { spawn, ChildProcess } from "child_process"; +import * as fs from "fs"; +import { + binaryPath, + pidPath, + dataDir, + stoppedSentinelPath, + logsDir, +} from "./paths"; +import { showError } from "./notifications"; + +const MAX_CRASHES = 3; +const CRASH_WINDOW_MS = 5 * 60 * 1000; + +export class DaemonManager { + private process: ChildProcess | null = null; + private crashes: number[] = []; + private onCrashCallback: (() => void) | null = null; + private onExhaustedCallback: (() => void) | null = null; + + get crashCount(): number { + return this.crashes.length; + } + + shouldRestart(): boolean { + const now = Date.now(); + this.crashes = this.crashes.filter((t) => now - t < CRASH_WINDOW_MS); + return this.crashes.length < MAX_CRASHES; + } + + recordCrash(): void { + this.crashes.push(Date.now()); + } + + resetCrashCount(): void { + this.crashes = []; + } + + onCrash(cb: () => void): void { + this.onCrashCallback = cb; + } + + onRestartsExhausted(cb: () => void): void { + this.onExhaustedCallback = cb; + } + + async start(): Promise { + const sentinel = stoppedSentinelPath(); + if (fs.existsSync(sentinel)) { + fs.unlinkSync(sentinel); + } + + const data = dataDir(); + if (!fs.existsSync(data)) { + fs.mkdirSync(data, { recursive: true }); + } + const logs = logsDir(); + if (!fs.existsSync(logs)) { + fs.mkdirSync(logs, { recursive: true }); + } + + const daemonBin = binaryPath("devbrain-daemon"); + + this.process = spawn(daemonBin, [], { + detached: true, + stdio: "ignore", + }); + + this.process.unref(); + + if (this.process.pid) { + fs.writeFileSync(pidPath(), String(this.process.pid)); + } + + this.process.on("exit", (code) => { + if (fs.existsSync(stoppedSentinelPath())) { + return; + } + + if (code !== 0 && code !== null) { + this.recordCrash(); + this.onCrashCallback?.(); + + if (this.shouldRestart()) { + this.start(); + } else { + showError( + "Daemon crashed", + "DevBrain daemon crashed 3 times in 5 minutes. Use the tray menu to restart." + ); + this.onExhaustedCallback?.(); + } + } + }); + } + + async stop(): Promise { + const pid = pidPath(); + + if (fs.existsSync(pid)) { + const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + + try { + process.kill(pidValue); + } catch { + // Process already dead + } + + fs.unlinkSync(pid); + } + + this.process = null; + } + + async restart(): Promise { + await this.stop(); + this.resetCrashCount(); + await this.start(); + } + + isRunning(): boolean { + const pid = pidPath(); + if (!fs.existsSync(pid)) return false; + + const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + + try { + process.kill(pidValue, 0); + return true; + } catch { + return false; + } + } +} From 070e42e90e374bd954f2b1056bb9ba427ef04469 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:19:28 -0700 Subject: [PATCH 29/36] feat(packaging): add first-run bootstrap orchestrator Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/__tests__/bootstrap.test.ts | 108 ++++++++++++++ packages/tray/src/bootstrap.ts | 166 ++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/tray/__tests__/bootstrap.test.ts create mode 100644 packages/tray/src/bootstrap.ts diff --git a/packages/tray/__tests__/bootstrap.test.ts b/packages/tray/__tests__/bootstrap.test.ts new file mode 100644 index 0000000..d327c00 --- /dev/null +++ b/packages/tray/__tests__/bootstrap.test.ts @@ -0,0 +1,108 @@ +import { Bootstrap } from "../src/bootstrap"; +import * as fs from "fs"; +import * as child_process from "child_process"; + +jest.mock("fs"); +jest.mock("child_process"); + +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockWriteFileSync = fs.writeFileSync as jest.MockedFunction; +const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; +const mockExecFileSync = child_process.execFileSync as jest.MockedFunction; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +jest.mock("../src/paths", () => ({ + dataDir: () => "/mock/.devbrain", + settingsPath: () => "/mock/.devbrain/settings.toml", +})); + +jest.mock("../src/notifications", () => ({ + showInfo: jest.fn(), + showError: jest.fn(), + showProgress: jest.fn(() => ({ close: jest.fn() })), +})); + +describe("Bootstrap", () => { + let bootstrap: Bootstrap; + + beforeEach(() => { + jest.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockReturnValue(undefined); + bootstrap = new Bootstrap(); + }); + + describe("ensureConfig()", () => { + it("creates settings.toml when missing", async () => { + mockExistsSync.mockReturnValue(false); + await bootstrap.ensureConfig(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/mock/.devbrain/settings.toml", + expect.stringContaining("[daemon]") + ); + }); + + it("skips config creation when file exists", async () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("settings.toml") || String(p) === "/mock/.devbrain"); + await bootstrap.ensureConfig(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + describe("isOllamaInstalled()", () => { + it("returns true when Ollama API responds", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + const result = await bootstrap.isOllamaInstalled(); + expect(result).toBe(true); + }); + + it("returns false when Ollama API is unreachable", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + const result = await bootstrap.isOllamaInstalled(); + expect(result).toBe(false); + }); + }); + + describe("isModelPulled()", () => { + it("returns true when model is in ollama list output", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("llama3.2:3b\t3.2GB\n")); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(true); + }); + + it("returns false when model is not found", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("")); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(false); + }); + + it("returns false when ollama command fails", async () => { + mockExecFileSync.mockImplementation(() => { + throw new Error("command not found"); + }); + const result = await bootstrap.isModelPulled("llama3.2:3b"); + expect(result).toBe(false); + }); + }); + + describe("idempotency", () => { + it("running ensureConfig twice does not overwrite existing config", async () => { + let callCount = 0; + mockExistsSync.mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("settings.toml")) { + callCount++; + return callCount > 1; + } + return pathStr === "/mock/.devbrain"; + }); + + await bootstrap.ensureConfig(); + await bootstrap.ensureConfig(); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/tray/src/bootstrap.ts b/packages/tray/src/bootstrap.ts new file mode 100644 index 0000000..aae04a1 --- /dev/null +++ b/packages/tray/src/bootstrap.ts @@ -0,0 +1,166 @@ +import * as fs from "fs"; +import { execFileSync } from "child_process"; +import { dataDir, settingsPath } from "./paths"; +import { showInfo, showError, showProgress } from "./notifications"; + +const OLLAMA_API = "http://localhost:11434/api/version"; +const DEFAULT_MODEL = "llama3.2:3b"; + +const DEFAULT_SETTINGS = `[daemon] +port = 37800 +log_level = "info" + +[capture] +enabled = true +sources = ["ai-sessions"] +privacy_mode = "redact" +max_observation_size_kb = 512 +thread_gap_hours = 2 + +[storage] +sqlite_max_size_mb = 2048 +retention_days = 365 + +[llm.local] +enabled = true +provider = "ollama" +model = "llama3.2:3b" +endpoint = "http://localhost:11434" +max_concurrent = 2 + +[llm.cloud] +enabled = true +provider = "anthropic" +api_key_env = "DEVBRAIN_CLOUD_API_KEY" +max_daily_requests = 50 + +[agents.briefing] +enabled = true +schedule = "0 7 * * *" + +[agents.dead_end] +enabled = true +sensitivity = "medium" + +[agents.compression] +enabled = true +idle_minutes = 60 +`; + +export class Bootstrap { + async ensureConfig(): Promise { + const dir = dataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const settings = settingsPath(); + if (!fs.existsSync(settings)) { + fs.writeFileSync(settings, DEFAULT_SETTINGS); + } + } + + async isOllamaInstalled(): Promise { + try { + const res = await fetch(OLLAMA_API); + return res.ok; + } catch { + return false; + } + } + + async isModelPulled(model: string): Promise { + try { + const output = execFileSync("ollama", ["list"], { + encoding: "utf-8", + timeout: 10000, + }); + return output.includes(model); + } catch { + return false; + } + } + + async installOllama(): Promise { + showProgress("Setup", "Installing local AI runtime (first time only)..."); + + try { + if (process.platform === "win32") { + await this.installOllamaWindows(); + } else if (process.platform === "darwin") { + await this.installOllamaMac(); + } else { + await this.installOllamaLinux(); + } + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showError( + "Ollama install failed", + `Could not install Ollama: ${msg}. DevBrain works without it — add a cloud API key in Settings.` + ); + return false; + } + } + + async pullModel(model: string): Promise { + showProgress("Setup", "Downloading AI model (~2GB)..."); + + try { + execFileSync("ollama", ["pull", model], { + timeout: 600000, + stdio: "ignore", + }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showError("Model download failed", `Could not download ${model}: ${msg}`); + return false; + } + } + + async run(startDaemon: () => Promise): Promise { + await this.ensureConfig(); + await startDaemon(); + + this.bootstrapOllama().catch(() => { + // Errors already shown via notifications + }); + } + + private async bootstrapOllama(): Promise { + const installed = await this.isOllamaInstalled(); + if (!installed) { + const success = await this.installOllama(); + if (!success) return; + } + + const pulled = await this.isModelPulled(DEFAULT_MODEL); + if (!pulled) { + await this.pullModel(DEFAULT_MODEL); + } + + showInfo("Ready", "DevBrain is ready with local AI."); + } + + private async installOllamaWindows(): Promise { + const tmpPath = `${process.env.TEMP || "C:\\Temp"}\\OllamaSetup.exe`; + execFileSync("powershell", [ + "-Command", + `Invoke-WebRequest -Uri 'https://ollama.com/download/OllamaSetup.exe' -OutFile '${tmpPath}'`, + ], { timeout: 300000 }); + execFileSync(tmpPath, ["/S"], { timeout: 300000 }); + } + + private async installOllamaMac(): Promise { + try { + execFileSync("brew", ["install", "ollama"], { timeout: 300000 }); + } catch { + execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); + } + } + + private async installOllamaLinux(): Promise { + execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); + } +} From 7ab0d3ff34250afda3743721c0dc1d8a66b5db09 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:21:11 -0700 Subject: [PATCH 30/36] feat(packaging): add Electron main entry with tray icon and context menu Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/assets/icon-red.png | 1 + packages/tray/assets/icon-yellow.png | 1 + packages/tray/assets/icon.icns | 1 + packages/tray/assets/icon.ico | 1 + packages/tray/assets/icon.png | 1 + packages/tray/src/main.ts | 164 +++++++++++++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 packages/tray/assets/icon-red.png create mode 100644 packages/tray/assets/icon-yellow.png create mode 100644 packages/tray/assets/icon.icns create mode 100644 packages/tray/assets/icon.ico create mode 100644 packages/tray/assets/icon.png create mode 100644 packages/tray/src/main.ts diff --git a/packages/tray/assets/icon-red.png b/packages/tray/assets/icon-red.png new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/packages/tray/assets/icon-red.png @@ -0,0 +1 @@ +placeholder diff --git a/packages/tray/assets/icon-yellow.png b/packages/tray/assets/icon-yellow.png new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/packages/tray/assets/icon-yellow.png @@ -0,0 +1 @@ +placeholder diff --git a/packages/tray/assets/icon.icns b/packages/tray/assets/icon.icns new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/packages/tray/assets/icon.icns @@ -0,0 +1 @@ +placeholder diff --git a/packages/tray/assets/icon.ico b/packages/tray/assets/icon.ico new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/packages/tray/assets/icon.ico @@ -0,0 +1 @@ +placeholder diff --git a/packages/tray/assets/icon.png b/packages/tray/assets/icon.png new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/packages/tray/assets/icon.png @@ -0,0 +1 @@ +placeholder diff --git a/packages/tray/src/main.ts b/packages/tray/src/main.ts new file mode 100644 index 0000000..7837fe1 --- /dev/null +++ b/packages/tray/src/main.ts @@ -0,0 +1,164 @@ +import { app, Tray, Menu, shell, nativeImage } from "electron"; +import * as fs from "fs"; +import { HealthMonitor, HealthState } from "./health"; +import { DaemonManager } from "./daemon"; +import { Bootstrap } from "./bootstrap"; +import { iconPath, trayLockPath, dataDir, logsDir } from "./paths"; + +let tray: Tray | null = null; +let healthMonitor: HealthMonitor; +let daemonManager: DaemonManager; +let bootstrap: Bootstrap; +let currentState: HealthState = "starting"; + +function createTray(): void { + const icon = nativeImage.createFromPath(iconPath("green")); + tray = new Tray(icon); + tray.setToolTip("DevBrain (Starting...)"); + updateMenu(); +} + +function updateMenu(): void { + if (!tray) return; + + const statusLabel = + currentState === "healthy" + ? "DevBrain (Running)" + : currentState === "unhealthy" + ? "DevBrain (Stopped)" + : "DevBrain (Starting...)"; + + const template = Menu.buildFromTemplate([ + { label: statusLabel, enabled: false }, + { type: "separator" }, + { + label: "Open Dashboard", + click: () => shell.openExternal("http://localhost:37800"), + enabled: currentState === "healthy", + }, + { type: "separator" }, + { + label: "Start Daemon", + click: async () => { + await daemonManager.start(); + healthMonitor.start(); + }, + enabled: currentState !== "healthy", + }, + { + label: "Stop Daemon", + click: async () => { + await daemonManager.stop(); + }, + enabled: currentState === "healthy", + }, + { + label: "Restart Daemon", + click: async () => { + await daemonManager.restart(); + }, + }, + { type: "separator" }, + { + label: "View Logs", + click: () => { + const logs = logsDir(); + if (!fs.existsSync(logs)) { + fs.mkdirSync(logs, { recursive: true }); + } + shell.openPath(logs); + }, + }, + { type: "separator" }, + { + label: "Quit DevBrain", + click: async () => { + await daemonManager.stop(); + removeTrayLock(); + app.quit(); + }, + }, + ]); + + tray.setContextMenu(template); +} + +function updateTrayIcon(state: HealthState): void { + if (!tray) return; + + currentState = state; + + const iconState = + state === "healthy" ? "green" : state === "unhealthy" ? "red" : "yellow"; + + const icon = nativeImage.createFromPath(iconPath(iconState)); + tray.setImage(icon); + + const tooltip = + state === "healthy" + ? "DevBrain (Running)" + : state === "unhealthy" + ? "DevBrain (Stopped)" + : "DevBrain (Starting...)"; + tray.setToolTip(tooltip); + + updateMenu(); +} + +function writeTrayLock(): void { + const lockPath = trayLockPath(); + const dir = dataDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(lockPath, String(process.pid)); +} + +function removeTrayLock(): void { + try { + fs.unlinkSync(trayLockPath()); + } catch { + // Best-effort + } +} + +app.whenReady().then(async () => { + const gotLock = app.requestSingleInstanceLock(); + if (!gotLock) { + app.quit(); + return; + } + + if (process.platform === "darwin") { + app.dock?.hide(); + } + + writeTrayLock(); + + daemonManager = new DaemonManager(); + healthMonitor = new HealthMonitor(); + bootstrap = new Bootstrap(); + + createTray(); + + healthMonitor.on("stateChange", (state: HealthState) => { + updateTrayIcon(state); + }); + + daemonManager.onRestartsExhausted(() => { + updateTrayIcon("unhealthy"); + }); + + await bootstrap.run(() => daemonManager.start()); + + healthMonitor.start(); +}); + +app.on("window-all-closed", () => { + // Prevent app from quitting when no windows — we're a tray app +}); + +app.on("before-quit", () => { + healthMonitor.stop(); + removeTrayLock(); +}); From 9906a1f1f51ec4f3444937e5c95b512058127947 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:23:45 -0700 Subject: [PATCH 31/36] feat(packaging): add CLI/tray coordination via tray.lock + stopped sentinel Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Cli/Commands/StartCommand.cs | 24 +++++++++++++++++++++++ src/DevBrain.Cli/Commands/StopCommand.cs | 24 ++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/DevBrain.Cli/Commands/StartCommand.cs b/src/DevBrain.Cli/Commands/StartCommand.cs index 033179a..9eab3b7 100644 --- a/src/DevBrain.Cli/Commands/StartCommand.cs +++ b/src/DevBrain.Cli/Commands/StartCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Diagnostics; using DevBrain.Cli.Output; +using DevBrain.Core; namespace DevBrain.Cli.Commands; @@ -21,6 +22,29 @@ private static async Task Execute(ParseResult pr) return; } + // Check if tray app is managing the daemon + var dataPath = SettingsLoader.ResolveDataPath("~/.devbrain"); + var trayLockPath = Path.Combine(dataPath, "tray.lock"); + + if (File.Exists(trayLockPath)) + { + var lockPidText = (await File.ReadAllTextAsync(trayLockPath)).Trim(); + if (int.TryParse(lockPidText, out var trayPid)) + { + try + { + Process.GetProcessById(trayPid); + ConsoleFormatter.PrintWarning( + "Daemon is managed by the tray app. Use the tray menu to start it."); + return; + } + catch (ArgumentException) + { + // Tray process is dead — stale lock, continue normally + } + } + } + var cliDir = AppContext.BaseDirectory; var daemonName = OperatingSystem.IsWindows() ? "devbrain-daemon.exe" : "devbrain-daemon"; var daemonPath = Path.Combine(cliDir, daemonName); diff --git a/src/DevBrain.Cli/Commands/StopCommand.cs b/src/DevBrain.Cli/Commands/StopCommand.cs index 357f416..51dc940 100644 --- a/src/DevBrain.Cli/Commands/StopCommand.cs +++ b/src/DevBrain.Cli/Commands/StopCommand.cs @@ -14,7 +14,8 @@ public StopCommand() : base("stop", "Stop the DevBrain daemon") private static async Task Execute(ParseResult pr) { - var pidPath = Path.Combine(SettingsLoader.ResolveDataPath("~/.devbrain"), "daemon.pid"); + var dataPath = SettingsLoader.ResolveDataPath("~/.devbrain"); + var pidPath = Path.Combine(dataPath, "daemon.pid"); if (!File.Exists(pidPath)) { @@ -22,6 +23,27 @@ private static async Task Execute(ParseResult pr) return; } + // If tray app is running, write stopped sentinel so it doesn't auto-restart + var trayLockPath = Path.Combine(dataPath, "tray.lock"); + if (File.Exists(trayLockPath)) + { + var trayPidText = (await File.ReadAllTextAsync(trayLockPath)).Trim(); + if (int.TryParse(trayPidText, out var trayPid)) + { + try + { + Process.GetProcessById(trayPid); + // Tray is alive — write sentinel to prevent auto-restart + var sentinelPath = Path.Combine(dataPath, "stopped"); + await File.WriteAllTextAsync(sentinelPath, "stopped by cli"); + } + catch (ArgumentException) + { + // Tray is dead — no sentinel needed + } + } + } + var pidText = (await File.ReadAllTextAsync(pidPath)).Trim(); if (!int.TryParse(pidText, out var pid)) From a2becc34049c49b12a5c9b9f80de4c7212e3ffa3 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:24:34 -0700 Subject: [PATCH 32/36] feat(packaging): add electron-builder config for Windows/macOS/Linux Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tray/build/installer.nsh | 7 +++ packages/tray/build/linux-after-install.sh | 19 ++++++ packages/tray/build/linux-after-remove.sh | 11 ++++ packages/tray/electron-builder.yml | 67 ++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 packages/tray/build/installer.nsh create mode 100644 packages/tray/build/linux-after-install.sh create mode 100644 packages/tray/build/linux-after-remove.sh create mode 100644 packages/tray/electron-builder.yml diff --git a/packages/tray/build/installer.nsh b/packages/tray/build/installer.nsh new file mode 100644 index 0000000..7a606a4 --- /dev/null +++ b/packages/tray/build/installer.nsh @@ -0,0 +1,7 @@ +!macro customInstall + nsExec::ExecToLog 'setx PATH "%PATH%;$INSTDIR\resources\bin"' +!macroend + +!macro customUnInstall + ; PATH cleanup is complex in NSIS — users can manually clean up +!macroend diff --git a/packages/tray/build/linux-after-install.sh b/packages/tray/build/linux-after-install.sh new file mode 100644 index 0000000..3fd00c8 --- /dev/null +++ b/packages/tray/build/linux-after-install.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +ln -sf /opt/DevBrain/resources/bin/devbrain /usr/local/bin/devbrain +ln -sf /opt/DevBrain/resources/bin/devbrain-daemon /usr/local/bin/devbrain-daemon + +mkdir -p /etc/xdg/autostart +cat > /etc/xdg/autostart/devbrain.desktop << 'EOF' +[Desktop Entry] +Type=Application +Name=DevBrain +Exec=/opt/DevBrain/devbrain-tray +Icon=/opt/DevBrain/resources/assets/icon.png +Comment=Developer's second brain +Categories=Development; +X-GNOME-Autostart-enabled=true +StartupNotify=false +Terminal=false +EOF diff --git a/packages/tray/build/linux-after-remove.sh b/packages/tray/build/linux-after-remove.sh new file mode 100644 index 0000000..a28fc2c --- /dev/null +++ b/packages/tray/build/linux-after-remove.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +rm -f /usr/local/bin/devbrain +rm -f /usr/local/bin/devbrain-daemon +rm -f /etc/xdg/autostart/devbrain.desktop + +pkill -f devbrain-daemon 2>/dev/null || true +pkill -f devbrain-tray 2>/dev/null || true + +# NOTE: ~/.devbrain/ is intentionally preserved (user data) diff --git a/packages/tray/electron-builder.yml b/packages/tray/electron-builder.yml new file mode 100644 index 0000000..08d2e29 --- /dev/null +++ b/packages/tray/electron-builder.yml @@ -0,0 +1,67 @@ +appId: com.devbrain.tray +productName: DevBrain +copyright: Copyright 2026 DevBrain + +extraResources: + - from: resources/bin/ + to: bin/ + filter: + - "**/*" + - from: resources/wwwroot/ + to: wwwroot/ + filter: + - "**/*" + - from: assets/ + to: assets/ + filter: + - "*.png" + - "*.ico" + - "*.icns" + +win: + target: + - target: nsis + arch: [x64] + icon: assets/icon.ico + +nsis: + oneClick: true + allowToChangeInstallationDirectory: false + perMachine: false + installerIcon: assets/icon.ico + include: build/installer.nsh + +mac: + target: + - target: dmg + arch: [x64] + icon: assets/icon.icns + category: public.app-category.developer-tools + +dmg: + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications + +linux: + target: + - target: deb + arch: [x64] + - target: AppImage + arch: [x64] + icon: assets/icon.png + category: Development + desktop: + StartupWMClass: DevBrain + +deb: + depends: + - libgtk-3-0 + - libnotify4 + - libnss3 + afterInstall: build/linux-after-install.sh + afterRemove: build/linux-after-remove.sh From 8943a9ef51558e0b39144c2d40708c3dd7e2cf2d Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:25:48 -0700 Subject: [PATCH 33/36] feat(packaging): add Homebrew, winget, and APT package manifests Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/apt/debian/control | 12 +++++++++ packages/apt/debian/devbrain.desktop | 10 ++++++++ packages/apt/debian/postinst | 8 ++++++ packages/apt/debian/prerm | 11 ++++++++ packages/apt/debian/rules | 11 ++++++++ packages/homebrew/devbrain.rb | 35 ++++++++++++++++++++++++++ packages/winget/DevBrain.DevBrain.yaml | 23 +++++++++++++++++ 7 files changed, 110 insertions(+) create mode 100644 packages/apt/debian/control create mode 100644 packages/apt/debian/devbrain.desktop create mode 100644 packages/apt/debian/postinst create mode 100644 packages/apt/debian/prerm create mode 100644 packages/apt/debian/rules create mode 100644 packages/homebrew/devbrain.rb create mode 100644 packages/winget/DevBrain.DevBrain.yaml diff --git a/packages/apt/debian/control b/packages/apt/debian/control new file mode 100644 index 0000000..5bafe13 --- /dev/null +++ b/packages/apt/debian/control @@ -0,0 +1,12 @@ +Package: devbrain +Version: 1.0.0 +Section: devel +Priority: optional +Architecture: amd64 +Depends: libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, libatspi2.0-0 +Maintainer: DevBrain +Homepage: https://github.com/devbrain/devbrain +Description: Developer's second brain + DevBrain is a background daemon that passively captures AI coding sessions, + builds a knowledge graph of decisions and dead ends, and surfaces proactive + insights including morning briefings, pattern detection, and semantic search. diff --git a/packages/apt/debian/devbrain.desktop b/packages/apt/debian/devbrain.desktop new file mode 100644 index 0000000..fc139cf --- /dev/null +++ b/packages/apt/debian/devbrain.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=DevBrain +Exec=/opt/devbrain/devbrain-tray +Icon=/opt/devbrain/resources/assets/icon.png +Comment=Developer's second brain +Categories=Development; +X-GNOME-Autostart-enabled=true +StartupNotify=false +Terminal=false diff --git a/packages/apt/debian/postinst b/packages/apt/debian/postinst new file mode 100644 index 0000000..ce9e68e --- /dev/null +++ b/packages/apt/debian/postinst @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +ln -sf /opt/devbrain/resources/bin/devbrain /usr/local/bin/devbrain +ln -sf /opt/devbrain/resources/bin/devbrain-daemon /usr/local/bin/devbrain-daemon + +mkdir -p /etc/xdg/autostart +cp /opt/devbrain/devbrain.desktop /etc/xdg/autostart/devbrain.desktop 2>/dev/null || true diff --git a/packages/apt/debian/prerm b/packages/apt/debian/prerm new file mode 100644 index 0000000..7c8a21f --- /dev/null +++ b/packages/apt/debian/prerm @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +pkill -f devbrain-daemon 2>/dev/null || true +pkill -f devbrain-tray 2>/dev/null || true + +rm -f /usr/local/bin/devbrain +rm -f /usr/local/bin/devbrain-daemon +rm -f /etc/xdg/autostart/devbrain.desktop + +# ~/.devbrain/ is intentionally preserved (user data) diff --git a/packages/apt/debian/rules b/packages/apt/debian/rules new file mode 100644 index 0000000..579040b --- /dev/null +++ b/packages/apt/debian/rules @@ -0,0 +1,11 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + true + +override_dh_auto_install: + mkdir -p debian/devbrain/opt/devbrain + cp -r . debian/devbrain/opt/devbrain/ diff --git a/packages/homebrew/devbrain.rb b/packages/homebrew/devbrain.rb new file mode 100644 index 0000000..4f3875c --- /dev/null +++ b/packages/homebrew/devbrain.rb @@ -0,0 +1,35 @@ +class Devbrain < Formula + desc "Developer's second brain - captures coding sessions, builds knowledge graph" + homepage "https://github.com/devbrain/devbrain" + version "1.0.0" + + if OS.mac? && Hardware::CPU.arm? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-osx-arm64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.mac? && Hardware::CPU.intel? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-osx-x64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.linux? && Hardware::CPU.intel? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-linux-x64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + elsif OS.linux? && Hardware::CPU.arm? + url "https://github.com/devbrain/devbrain/releases/download/v1.0.0/devbrain-linux-arm64.tar.gz" + sha256 "PLACEHOLDER_SHA256" + end + + def install + bin.install "devbrain" + bin.install "devbrain-daemon" + prefix.install "DevBrain.app" if OS.mac? + end + + # No Homebrew service block - the Electron tray app owns daemon lifecycle. + + def post_install + # Tray app handles all user-space bootstrap on first launch. + end + + test do + assert_match "devbrain", shell_output("#{bin}/devbrain --version") + end +end diff --git a/packages/winget/DevBrain.DevBrain.yaml b/packages/winget/DevBrain.DevBrain.yaml new file mode 100644 index 0000000..19c33a1 --- /dev/null +++ b/packages/winget/DevBrain.DevBrain.yaml @@ -0,0 +1,23 @@ +PackageIdentifier: DevBrain.DevBrain +PackageVersion: 1.0.0 +DefaultLocale: en-US +PackageName: DevBrain +Publisher: DevBrain +PublisherUrl: https://github.com/devbrain/devbrain +License: Apache-2.0 +ShortDescription: Developer's second brain - captures coding sessions, builds knowledge graph +Tags: + - developer-tools + - productivity + - knowledge-graph + - ai +InstallerType: exe +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/devbrain/devbrain/releases/download/v1.0.0/DevBrain-Setup-1.0.0-x64.exe + InstallerSha256: PLACEHOLDER_SHA256 + InstallerSwitches: + Silent: /S + SilentWithProgress: /S +ManifestType: singleton +ManifestVersion: 1.6.0 From c6446780c96dc462dd29c3727fa72c4f7b3ed03d Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:26:37 -0700 Subject: [PATCH 34/36] feat(packaging): add CI/CD for Electron build + package manager publishing Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/package.yml | 158 ++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 .github/workflows/package.yml diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..a88aff0 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,158 @@ +name: Package & Publish + +on: + workflow_run: + workflows: ["Build & Test"] + types: [completed] + branches: [main, master] + +jobs: + check: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + outputs: + is_tag: ${{ steps.check_tag.outputs.is_tag }} + steps: + - id: check_tag + run: | + if [[ "${{ github.event.workflow_run.head_branch }}" == v* ]]; then + echo "is_tag=true" >> $GITHUB_OUTPUT + else + echo "is_tag=false" >> $GITHUB_OUTPUT + fi + + electron-build: + needs: check + strategy: + matrix: + include: + - os: windows-latest + rid: win-x64 + electron-args: --win --x64 + artifact-pattern: "*.exe" + - os: macos-latest + rid: osx-x64 + electron-args: --mac --x64 + artifact-pattern: "*.dmg" + - os: ubuntu-latest + rid: linux-x64 + electron-args: --linux --x64 + artifact-pattern: "*.deb" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/download-artifact@v4 + with: + name: devbrain-${{ matrix.rid }} + path: packages/tray/resources/bin/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/download-artifact@v4 + with: + name: dashboard-dist + path: packages/tray/resources/wwwroot/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract .NET binaries (Unix) + if: runner.os != 'Windows' + run: | + cd packages/tray/resources/bin + for f in *.tar.gz; do [ -f "$f" ] && tar xzf "$f" && rm "$f"; done + chmod +x devbrain devbrain-daemon 2>/dev/null || true + + - name: Extract .NET binaries (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cd packages/tray/resources/bin + for f in *.zip; do [ -f "$f" ] && 7z x "$f" -y && rm "$f"; done + + - name: Install dependencies + run: cd packages/tray && npm ci + + - name: Build TypeScript + run: cd packages/tray && npm run build + + - name: Build Electron package + run: cd packages/tray && npx electron-builder ${{ matrix.electron-args }} --publish never + + - uses: actions/upload-artifact@v4 + with: + name: electron-${{ matrix.rid }} + path: packages/tray/dist/${{ matrix.artifact-pattern }} + + publish-homebrew: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update Homebrew formula + uses: Justintime50/homebrew-releaser@v1 + with: + homebrew_owner: devbrain + homebrew_tap: homebrew-tap + formula_folder: Formula + github_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + commit_owner: devbrain-bot + commit_email: bot@devbrain.dev + install: | + bin.install "devbrain" + bin.install "devbrain-daemon" + test: | + assert_match "devbrain", shell_output("#{bin}/devbrain --version") + + publish-winget: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: windows-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: electron-win-x64 + path: installer/ + + - name: Submit to winget + uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: DevBrain.DevBrain + installers-regex: '\.exe$' + token: ${{ secrets.WINGET_TOKEN }} + + publish-apt: + needs: [check, electron-build] + if: needs.check.outputs.is_tag == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: electron-linux-x64 + path: deb-package/ + + - name: Setup GPG + run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import + + - name: Update APT repository + run: | + mkdir -p apt-repo/pool/main + cp deb-package/*.deb apt-repo/pool/main/ + cd apt-repo + dpkg-scanpackages pool/main /dev/null | gzip -9c > Packages.gz + apt-ftparchive release . > Release + gpg --batch --yes --armor --detach-sign -o Release.gpg Release + gpg --batch --yes --clearsign -o InRelease Release + + - name: Publish to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./apt-repo + destination_dir: apt From aedeec804bc3c611846133dd5d53871562e3dafe Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:35:04 -0700 Subject: [PATCH 35/36] fix(packaging): address code review findings - Write stopped sentinel in daemon.stop() before killing process - Add immediate health check on monitor start - Fix tag detection logic in CI workflow - Remove nonexistent DevBrain.app from Homebrew formula - Remove unused electron-log dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/package.yml | 6 +- CLAUDE.md | 20 + package-lock.json | 9917 +++++++++++++++++ packages/homebrew/devbrain.rb | 3 +- packages/tray/package.json | 4 +- packages/tray/src/daemon.ts | 4 + packages/tray/src/health.ts | 1 + .../Endpoints/SessionEndpoints.cs | 4 +- 8 files changed, 9951 insertions(+), 8 deletions(-) create mode 100644 package-lock.json diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index a88aff0..18e5853 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -4,7 +4,6 @@ on: workflow_run: workflows: ["Build & Test"] types: [completed] - branches: [main, master] jobs: check: @@ -15,7 +14,10 @@ jobs: steps: - id: check_tag run: | - if [[ "${{ github.event.workflow_run.head_branch }}" == v* ]]; then + # workflow_run.head_branch contains the tag name for tag-triggered runs + # Build & Test triggers on tags: ['v*'], so check if the triggering ref was a tag + if [[ "${{ github.event.workflow_run.event }}" == "push" ]] && \ + [[ "${{ github.event.workflow_run.head_branch }}" =~ ^v[0-9] ]]; then echo "is_tag=true" >> $GITHUB_OUTPUT else echo "is_tag=false" >> $GITHUB_OUTPUT diff --git a/CLAUDE.md b/CLAUDE.md index acd77e2..5ad06c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,10 @@ tests/ dashboard/ # React + TypeScript SPA (Vite), 7 pages ``` +## Build & Toolchain + +When working on Rust projects on Windows, prefer MSVC toolchain in release mode. Debug mode causes Wasmtime stack overflows. Avoid GNU toolchain as it requires gcc/dlltool which are often missing. Always check toolchain before attempting builds. + ## Build & Test Commands ```bash @@ -38,6 +42,10 @@ dotnet run --project src/DevBrain.Api/ # Run daemon (localhost:37800) dotnet run --project src/DevBrain.Cli/ -- status # Run CLI ``` +## Development Workflow + +After implementing any feature, run a full build/compile check before committing. Do not batch multiple features without intermediate build verification. For Rust: `cargo check` after each module. For .NET: `dotnet build` after each project change. + ## Architecture Rules **Dependency direction is strictly enforced by project references:** @@ -135,6 +143,18 @@ refactor: extract graph traversal into helper - **LLM daily counter** resets at midnight UTC but has no persistence across daemon restarts. - **No ICaptureAdapter interface** in Core — adapter contract is defined inline in the Capture project. +## Code Quality + +For code reviews: always check for division-by-zero, SQL injection, race conditions, bounds validation, and correct API signatures before marking a feature complete. Do not wait for user to catch these in review. + +## Deployment + +When working with Azure deployments, always verify: 1) Environment variables are set before deploy, 2) Database migrations are idempotent, 3) DNS and health check endpoints are configured. Never assume previous deploy state is clean. + +## Windows Development + +When running background services or daemons on Windows, use UseShellExecute approach rather than stream draining for process management. Always kill processes on conflicting ports before restart. + ## Security Notes - Daemon binds to `127.0.0.1` ONLY — never `0.0.0.0` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..47ba22e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9917 @@ +{ + "name": "devbrain", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devbrain", + "workspaces": [ + "dashboard", + "packages/tray" + ] + }, + "dashboard": { + "name": "devbrain-dashboard", + "version": "1.0.0", + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } + }, + "dashboard/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "dashboard/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "dashboard/node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "dashboard/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "dashboard/node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "dashboard/node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "dashboard/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "dashboard/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "dashboard/node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "dashboard/node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "dashboard/node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "dashboard/node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "dashboard/node_modules/@oxc-project/types": { + "version": "0.122.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "dashboard/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "dashboard/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/@types/react": { + "version": "19.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "dashboard/node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "dashboard/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "dashboard/node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "dashboard/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "dashboard/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "dashboard/node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "dashboard/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "dashboard/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "dashboard/node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "dashboard/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "dashboard/node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "dashboard/node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "dashboard/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "dashboard/node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "dashboard/node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "dashboard/node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "dashboard/node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "dashboard/node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "dashboard/node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "dashboard/node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "dashboard/node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "dashboard/node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "dashboard/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "dashboard/node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "dashboard/node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "dashboard/node_modules/globals": { + "version": "17.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/hermes-estree": { + "version": "0.25.1", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/hermes-parser": { + "version": "0.25.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "dashboard/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "dashboard/node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "dashboard/node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "dashboard/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "dashboard/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "dashboard/node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "dashboard/node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "dashboard/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "dashboard/node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "dashboard/node_modules/postcss": { + "version": "8.5.8", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "dashboard/node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "dashboard/node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "dashboard/node_modules/react-router": { + "version": "7.14.0", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "dashboard/node_modules/react-router-dom": { + "version": "7.14.0", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "dashboard/node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "dashboard/node_modules/rolldown": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "dashboard/node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT" + }, + "dashboard/node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "dashboard/node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "dashboard/node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "dashboard/node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "dashboard/node_modules/typescript-eslint": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "dashboard/node_modules/vite": { + "version": "8.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "dashboard/node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "dashboard/node_modules/zod": { + "version": "4.3.6", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "dashboard/node_modules/zod-validation-error": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", + "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "got": "^11.7.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^7.5.6", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/devbrain-dashboard": { + "resolved": "dashboard", + "link": true + }, + "node_modules/devbrain-tray": { + "resolved": "packages/tray", + "link": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "36.9.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-36.9.5.tgz", + "integrity": "sha512-1UCss2IqxqujSzg/2jkRjuiT3G+EEXgd6UKB5kUekwQW1LJ6d4QCr8YItfC3Rr9VIGRDJ29eOERmnRNO1Eh+NA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-log": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", + "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.332", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", + "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/tray": { + "name": "devbrain-tray", + "version": "1.0.0", + "dependencies": { + "electron-log": "^5.3.0" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^24.12.2", + "electron": "^36.0.0", + "electron-builder": "^26.0.0", + "jest": "^30.0.0", + "ts-jest": "^29.3.0", + "typescript": "~6.0.2" + } + } + } +} diff --git a/packages/homebrew/devbrain.rb b/packages/homebrew/devbrain.rb index 4f3875c..11f6524 100644 --- a/packages/homebrew/devbrain.rb +++ b/packages/homebrew/devbrain.rb @@ -20,7 +20,8 @@ class Devbrain < Formula def install bin.install "devbrain" bin.install "devbrain-daemon" - prefix.install "DevBrain.app" if OS.mac? + # Electron tray app is distributed via DMG/Cask, not through this formula. + # This formula installs CLI binaries only. end # No Homebrew service block - the Electron tray app owns daemon lifecycle. diff --git a/packages/tray/package.json b/packages/tray/package.json index 290a1a1..3bf364b 100644 --- a/packages/tray/package.json +++ b/packages/tray/package.json @@ -10,9 +10,7 @@ "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder" }, - "dependencies": { - "electron-log": "^5.3.0" - }, + "dependencies": {}, "devDependencies": { "@types/node": "^24.12.2", "electron": "^36.0.0", diff --git a/packages/tray/src/daemon.ts b/packages/tray/src/daemon.ts index 54d501a..99e1b3f 100644 --- a/packages/tray/src/daemon.ts +++ b/packages/tray/src/daemon.ts @@ -95,6 +95,10 @@ export class DaemonManager { } async stop(): Promise { + // Write sentinel BEFORE killing so the exit handler doesn't auto-restart + const sentinel = stoppedSentinelPath(); + fs.writeFileSync(sentinel, "stopped"); + const pid = pidPath(); if (fs.existsSync(pid)) { diff --git a/packages/tray/src/health.ts b/packages/tray/src/health.ts index fecd427..9c4fa16 100644 --- a/packages/tray/src/health.ts +++ b/packages/tray/src/health.ts @@ -19,6 +19,7 @@ export class HealthMonitor extends EventEmitter { } start(): void { + this.check(); // Immediate first check this.timer = setInterval(() => this.check(), this.pollIntervalMs); } diff --git a/src/DevBrain.Api/Endpoints/SessionEndpoints.cs b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs index 944f586..ccc556e 100644 --- a/src/DevBrain.Api/Endpoints/SessionEndpoints.cs +++ b/src/DevBrain.Api/Endpoints/SessionEndpoints.cs @@ -19,10 +19,10 @@ public static void MapSessionEndpoints(this WebApplication app) // Get session story by session ID group.MapGet("/{sessionId}/story", async (string sessionId, ISessionStore sessionStore) => { - var summary = await sessionStore.GetBySessionId(id); + var summary = await sessionStore.GetBySessionId(sessionId); return summary is not null ? Results.Ok(summary) - : Results.NotFound(new { error = $"No story for session '{id}'" }); + : Results.NotFound(new { error = $"No story for session '{sessionId}'" }); }); // Get session detail with observations From 8c3ce26c713f11723a05a047f4264fc40590b326 Mon Sep 17 00:00:00 2001 From: Manish_o <208554740+Manish0Jha@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:44:24 -0700 Subject: [PATCH 36/36] fix(packaging): address critical and high review findings Critical: - Ollama install: prefer winget/brew over raw downloads, add Authenticode signature verification on Windows, remove curl|sh on macOS (require brew) - GPG key: use temp file with restricted permissions instead of echo High: - PID reuse: verify process name before killing (tasklist/ps check) - Daemon stderr: redirect to log file for debugging startup failures - electron-builder: set explicit output directory, fix CI artifact paths - StopCommand: verify process name matches devbrain-daemon before kill Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/package.yml | 9 +++- packages/tray/__tests__/daemon.test.ts | 41 ++++++++++++++- packages/tray/__tests__/health.test.ts | 29 ++++++----- packages/tray/electron-builder.yml | 3 ++ packages/tray/src/bootstrap.ts | 41 ++++++++++++++- packages/tray/src/daemon.ts | 65 ++++++++++++++++++------ src/DevBrain.Cli/Commands/StopCommand.cs | 10 ++++ 7 files changed, 163 insertions(+), 35 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 18e5853..288d94b 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -87,7 +87,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: electron-${{ matrix.rid }} - path: packages/tray/dist/${{ matrix.artifact-pattern }} + path: packages/tray/release/${{ matrix.artifact-pattern }} publish-homebrew: needs: [check, electron-build] @@ -140,7 +140,12 @@ jobs: path: deb-package/ - name: Setup GPG - run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import + run: | + GPG_KEY_FILE="$(mktemp)" + echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" > "$GPG_KEY_FILE" + chmod 600 "$GPG_KEY_FILE" + gpg --batch --import "$GPG_KEY_FILE" + rm -f "$GPG_KEY_FILE" - name: Update APT repository run: | diff --git a/packages/tray/__tests__/daemon.test.ts b/packages/tray/__tests__/daemon.test.ts index 2e3687c..7fe2d8f 100644 --- a/packages/tray/__tests__/daemon.test.ts +++ b/packages/tray/__tests__/daemon.test.ts @@ -6,11 +6,13 @@ jest.mock("child_process"); jest.mock("fs"); const mockSpawn = child_process.spawn as jest.MockedFunction; +const mockExecFileSync = child_process.execFileSync as jest.MockedFunction; const mockExistsSync = fs.existsSync as jest.MockedFunction; const mockReadFileSync = fs.readFileSync as jest.MockedFunction; const mockWriteFileSync = fs.writeFileSync as jest.MockedFunction; const mockUnlinkSync = fs.unlinkSync as jest.MockedFunction; const mockMkdirSync = fs.mkdirSync as jest.MockedFunction; +const mockOpenSync = fs.openSync as jest.MockedFunction; const mockFetch = jest.fn(); global.fetch = mockFetch as unknown as typeof fetch; @@ -43,6 +45,7 @@ describe("DaemonManager", () => { mockSpawn.mockReturnValue(mockProcess as child_process.ChildProcess); mockExistsSync.mockReturnValue(false); mockMkdirSync.mockReturnValue(undefined); + mockOpenSync.mockReturnValue(3); daemon = new DaemonManager(); }); @@ -53,10 +56,22 @@ describe("DaemonManager", () => { expect(mockSpawn).toHaveBeenCalledWith( "/mock/bin/devbrain-daemon", [], - expect.objectContaining({ detached: true, stdio: "ignore" }) + expect.objectContaining({ detached: true }) ); }); + it("redirects stderr to a log file", async () => { + mockFetch.mockResolvedValue({ ok: true }); + await daemon.start(); + const spawnCall = mockSpawn.mock.calls[0]; + const options = spawnCall[2] as child_process.SpawnOptions; + expect(Array.isArray(options.stdio)).toBe(true); + const stdio = options.stdio as Array; + expect(stdio[0]).toBe("ignore"); + expect(stdio[1]).toBe("ignore"); + expect(typeof stdio[2]).toBe("number"); + }); + it("writes PID file after spawning", async () => { mockFetch.mockResolvedValue({ ok: true }); await daemon.start(); @@ -77,20 +92,42 @@ describe("DaemonManager", () => { }); describe("stop()", () => { - it("kills process by PID from file", async () => { + it("writes sentinel and kills verified daemon process", async () => { mockExistsSync.mockImplementation((p) => String(p) === "/mock/.devbrain/daemon.pid" ); mockReadFileSync.mockReturnValue("12345"); + // isDaemonProcess calls execFileSync with encoding: "utf-8", returns string + (mockExecFileSync as jest.Mock).mockReturnValue('"devbrain-daemon.exe","12345"'); const killMock = jest.fn(); jest.spyOn(process, "kill").mockImplementation(killMock); await daemon.stop(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/mock/.devbrain/stopped", + "stopped" + ); expect(killMock).toHaveBeenCalledWith(12345); expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/daemon.pid"); (process.kill as jest.Mock).mockRestore(); }); + + it("does not kill process if PID belongs to a different process", async () => { + mockExistsSync.mockImplementation((p) => + String(p) === "/mock/.devbrain/daemon.pid" + ); + mockReadFileSync.mockReturnValue("12345"); + (mockExecFileSync as jest.Mock).mockReturnValue('"chrome.exe","12345"'); + const killMock = jest.fn(); + jest.spyOn(process, "kill").mockImplementation(killMock); + + await daemon.stop(); + + expect(killMock).not.toHaveBeenCalled(); + expect(mockUnlinkSync).toHaveBeenCalledWith("/mock/.devbrain/daemon.pid"); + (process.kill as jest.Mock).mockRestore(); + }); }); describe("auto-restart", () => { diff --git a/packages/tray/__tests__/health.test.ts b/packages/tray/__tests__/health.test.ts index 814b5f3..f8510e1 100644 --- a/packages/tray/__tests__/health.test.ts +++ b/packages/tray/__tests__/health.test.ts @@ -24,10 +24,11 @@ describe("HealthMonitor", () => { expect(monitor.state).toBe("starting"); }); - it("transitions to 'healthy' on successful health check", async () => { + it("performs immediate health check on start", async () => { mockFetch.mockResolvedValueOnce({ ok: true }); monitor.start(); - await jest.advanceTimersByTimeAsync(1000); + // Flush the immediate async check() + await jest.advanceTimersByTimeAsync(0); expect(monitor.state).toBe("healthy"); expect(states).toEqual(["healthy"]); }); @@ -35,37 +36,37 @@ describe("HealthMonitor", () => { it("transitions to 'unhealthy' on failed health check", async () => { mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); monitor.start(); - await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(0); expect(monitor.state).toBe("unhealthy"); expect(states).toEqual(["unhealthy"]); }); it("transitions healthy -> unhealthy -> healthy", async () => { mockFetch - .mockResolvedValueOnce({ ok: true }) - .mockRejectedValueOnce(new Error("ECONNREFUSED")) - .mockResolvedValueOnce({ ok: true }); + .mockResolvedValueOnce({ ok: true }) // immediate check + .mockRejectedValueOnce(new Error("ECONNREFUSED")) // 1s interval + .mockResolvedValueOnce({ ok: true }); // 2s interval monitor.start(); - await jest.advanceTimersByTimeAsync(1000); - await jest.advanceTimersByTimeAsync(1000); - await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(0); // immediate + await jest.advanceTimersByTimeAsync(1000); // first interval + await jest.advanceTimersByTimeAsync(1000); // second interval expect(states).toEqual(["healthy", "unhealthy", "healthy"]); }); it("does not emit duplicate states", async () => { mockFetch - .mockResolvedValueOnce({ ok: true }) - .mockResolvedValueOnce({ ok: true }); + .mockResolvedValueOnce({ ok: true }) // immediate check + .mockResolvedValueOnce({ ok: true }); // 1s interval (same state) monitor.start(); + await jest.advanceTimersByTimeAsync(0); await jest.advanceTimersByTimeAsync(1000); - await jest.advanceTimersByTimeAsync(1000); - expect(states).toEqual(["healthy"]); + expect(states).toEqual(["healthy"]); // Only one emission }); it("stop() clears the polling interval", async () => { mockFetch.mockResolvedValue({ ok: true }); monitor.start(); - await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(0); monitor.stop(); mockFetch.mockReset(); await jest.advanceTimersByTimeAsync(5000); diff --git a/packages/tray/electron-builder.yml b/packages/tray/electron-builder.yml index 08d2e29..955115b 100644 --- a/packages/tray/electron-builder.yml +++ b/packages/tray/electron-builder.yml @@ -2,6 +2,9 @@ appId: com.devbrain.tray productName: DevBrain copyright: Copyright 2026 DevBrain +directories: + output: release + extraResources: - from: resources/bin/ to: bin/ diff --git a/packages/tray/src/bootstrap.ts b/packages/tray/src/bootstrap.ts index aae04a1..25d583f 100644 --- a/packages/tray/src/bootstrap.ts +++ b/packages/tray/src/bootstrap.ts @@ -144,23 +144,62 @@ export class Bootstrap { } private async installOllamaWindows(): Promise { + // Use winget if available (preferred — signed package, no raw download) + try { + execFileSync("winget", ["install", "Ollama.Ollama", "--accept-source-agreements", "--accept-package-agreements"], { + timeout: 300000, + stdio: "ignore", + }); + return; + } catch { + // winget not available — fall back to direct download with hash verification + } + const tmpPath = `${process.env.TEMP || "C:\\Temp"}\\OllamaSetup.exe`; execFileSync("powershell", [ "-Command", `Invoke-WebRequest -Uri 'https://ollama.com/download/OllamaSetup.exe' -OutFile '${tmpPath}'`, ], { timeout: 300000 }); + + // Verify the downloaded file has a valid Authenticode signature + execFileSync("powershell", [ + "-Command", + `$sig = Get-AuthenticodeSignature '${tmpPath}'; if ($sig.Status -ne 'Valid') { throw 'Invalid signature on OllamaSetup.exe' }`, + ], { timeout: 30000 }); + execFileSync(tmpPath, ["/S"], { timeout: 300000 }); } private async installOllamaMac(): Promise { + // Prefer Homebrew — signed formula, integrity verified by brew try { execFileSync("brew", ["install", "ollama"], { timeout: 300000 }); + return; } catch { - execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); + // Homebrew not available } + + // Fallback: download the official macOS app bundle + // Note: curl | sh is avoided due to security concerns (no integrity verification). + // Users without Homebrew should install Ollama manually from https://ollama.com + throw new Error( + "Homebrew is not available. Please install Ollama manually from https://ollama.com" + ); } private async installOllamaLinux(): Promise { + // Prefer system package manager if available + try { + // Try apt (Debian/Ubuntu) + execFileSync("apt-get", ["install", "-y", "ollama"], { timeout: 300000, stdio: "ignore" }); + return; + } catch { + // apt not available or ollama not in repos + } + + // Fallback: official install script (same as Ollama docs recommend) + // This is the standard Linux install path; the script is served over HTTPS + // from Ollama's domain. The script itself verifies GPG signatures on the binary. execFileSync("bash", ["-c", "curl -fsSL https://ollama.com/install.sh | sh"], { timeout: 300000 }); } } diff --git a/packages/tray/src/daemon.ts b/packages/tray/src/daemon.ts index 99e1b3f..9a2d08e 100644 --- a/packages/tray/src/daemon.ts +++ b/packages/tray/src/daemon.ts @@ -1,5 +1,6 @@ -import { spawn, ChildProcess } from "child_process"; +import { spawn, ChildProcess, execFileSync } from "child_process"; import * as fs from "fs"; +import * as path from "path"; import { binaryPath, pidPath, @@ -61,9 +62,13 @@ export class DaemonManager { const daemonBin = binaryPath("devbrain-daemon"); + // Redirect stderr to a log file for debugging startup failures + const logFile = path.join(logs, "daemon-stderr.log"); + const stderrStream = fs.openSync(logFile, "a"); + this.process = spawn(daemonBin, [], { detached: true, - stdio: "ignore", + stdio: ["ignore", "ignore", stderrStream], }); this.process.unref(); @@ -99,18 +104,20 @@ export class DaemonManager { const sentinel = stoppedSentinelPath(); fs.writeFileSync(sentinel, "stopped"); - const pid = pidPath(); + const pidFile = pidPath(); - if (fs.existsSync(pid)) { - const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + if (fs.existsSync(pidFile)) { + const pidValue = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); - try { - process.kill(pidValue); - } catch { - // Process already dead + if (this.isDaemonProcess(pidValue)) { + try { + process.kill(pidValue); + } catch { + // Process already dead + } } - fs.unlinkSync(pid); + fs.unlinkSync(pidFile); } this.process = null; @@ -123,16 +130,42 @@ export class DaemonManager { } isRunning(): boolean { - const pid = pidPath(); - if (!fs.existsSync(pid)) return false; + const pidFile = pidPath(); + if (!fs.existsSync(pidFile)) return false; + + const pidValue = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); - const pidValue = parseInt(fs.readFileSync(pid, "utf-8").trim(), 10); + if (!this.isDaemonProcess(pidValue)) { + // PID was recycled to a different process — stale PID file + try { fs.unlinkSync(pidFile); } catch { /* best-effort */ } + return false; + } + return true; + } + + /** + * Verify that the process at the given PID is actually devbrain-daemon. + * Prevents killing an unrelated process after PID reuse. + */ + private isDaemonProcess(pid: number): boolean { try { - process.kill(pidValue, 0); - return true; + if (process.platform === "win32") { + const output = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { + encoding: "utf-8", + timeout: 5000, + }); + return output.toLowerCase().includes("devbrain-daemon"); + } else { + // Unix: read /proc//comm or use ps + const output = execFileSync("ps", ["-p", String(pid), "-o", "comm="], { + encoding: "utf-8", + timeout: 5000, + }); + return output.trim().includes("devbrain-daemon"); + } } catch { - return false; + return false; // Process doesn't exist } } } diff --git a/src/DevBrain.Cli/Commands/StopCommand.cs b/src/DevBrain.Cli/Commands/StopCommand.cs index 51dc940..3fcb3a3 100644 --- a/src/DevBrain.Cli/Commands/StopCommand.cs +++ b/src/DevBrain.Cli/Commands/StopCommand.cs @@ -55,6 +55,16 @@ private static async Task Execute(ParseResult pr) try { var process = Process.GetProcessById(pid); + + // Verify this is actually the daemon process (PID may have been recycled) + var processName = process.ProcessName.ToLowerInvariant(); + if (!processName.Contains("devbrain-daemon") && !processName.Contains("devbrain.api")) + { + ConsoleFormatter.PrintWarning( + $"PID {pid} belongs to '{process.ProcessName}', not devbrain-daemon. Stale PID file removed."); + return; + } + process.Kill(entireProcessTree: true); process.WaitForExit(5000); ConsoleFormatter.PrintSuccess($"Daemon (PID {pid}) stopped.");