From baafbd7ff912e7b64db9124bb1df9df56b2973ee Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 18 Jul 2026 13:26:03 -0500 Subject: [PATCH 1/2] feat(cli): add settings verb for settings.json get/set/list Expose headless settings CRUD against the shared settings.json store with merge-safe writes, secret redaction, and tests so agents can configure paths and managed deploy without the GUI. --- docs/knowledgebase/agent-action-parity.md | 1 + docs/knowledgebase/agent-native-audit.md | 3 +- docs/knowledgebase/core-cli-reference.md | 30 +++ src/ModSync.Core/CLI/ModBuildConverter.cs | 198 ++++++++++++++++- .../Services/Settings/SettingsFileStore.cs | 203 ++++++++++++++++++ src/ModSync.Tests/SettingsCliTests.cs | 142 ++++++++++++ 6 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 src/ModSync.Core/Services/Settings/SettingsFileStore.cs create mode 100644 src/ModSync.Tests/SettingsCliTests.cs diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 046277b7..94c98a6f 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -65,6 +65,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | Wizard UI | `WizardFlowHeadlessTests` | Page flow without full desktop | | GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button + `LoadInstructionTextAsync` markdown (no clipboard), Welcome→ValidatePage key controls, compact ScrollViewer layout, ValidatePage log splitter | | Guide ingest | `GuideIngestionTests` | `--stdin` / `--parse-directions` draft + sandboxed paths | +| Settings CLI | `SettingsCliTests` | `settings list|get|set` merges into `settings.json` | | Wizard validation UX | `WizardValidationStagePresenter`, `ValidationPipelineDialogMapper` | Stage cards / dialog mapper parity ([PR #110](https://github.com/th3w1zard1/ModSync/pull/110)) | | Version alignment | `ReleaseVersionAlignmentTests` | Release metadata consistency | diff --git a/docs/knowledgebase/agent-native-audit.md b/docs/knowledgebase/agent-native-audit.md index 57b0948d..117e2e72 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -52,6 +52,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Paste / ingest guide | GUI clipboard; CLI `convert --stdin` / `-i` + `--parse-directions` | Yes (file/stdin); OS clipboard `[UI]` | | `modsync://` open/install link | `--modsync=` / URI argv → handoff | Yes (consume); Settings toggle deferred | | Run validation | `ValidatePage` or `validate --full` / `--dry-run` | Yes (selection flags differ) | +| Read/write app settings | Settings UI / `settings.json` | Partial — `settings list|get|set` CLI; theme/spoiler UI still `[UI]` | | Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d`; status/stop `[UI]` | | Post-download FOMOD configure | GUI after Fetch Downloads | Yes — CLI TTY / `--fomod-choices` / `--fomod-skip` | | FOMOD configure-before-validate/install gate | GUI + Core `FomodConfigurationGate` | Yes — shared fail-closed gate | @@ -77,7 +78,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Tool layer | Examples | |------------|----------| -| Atomic CLI verbs | `validate`, `install`, `convert`, `merge`, `holopatcher` | +| Atomic CLI verbs | `validate`, `install`, `convert`, `merge`, `settings`, `holopatcher` | | Composition | `install -d --concurrent --best-effort -y`; `convert --stdin --parse-directions` | | Scripts | Thin wrappers (`cli_validate.sh`, `run_headless_tests.sh`, `cli_full_build_pipeline.sh`) | diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index 589b537f..b9eb3022 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -183,6 +183,36 @@ dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "Name~AutoGenerateLo --- +### `settings` + +Read or write keys in persisted `settings.json` (same file as the GUI `AppSettings` store). Merges into the existing JSON document without removing unrelated GUI-owned keys. + +| Flag | Required | Description | +|------|----------|-------------| +| `-a` / `--action` | Yes | `list`, `get`, or `set` | +| `-k` / `--key` | For get/set | Setting key (camelCase JSON names, e.g. `sourcePath`, `managedDeploymentEnabled`) | +| `--value` | For set | New value (`true`/`false`, numbers, JSON literals, or strings). Omit or pass empty to remove the key. | +| `--settings-dir` | No | Settings directory (default: platform AppData / `~/.config/ModSync`) | +| `--json` | No | JSON output for list/get/set | +| `--reveal-secrets` | No | Include sensitive values such as `nexusModsApiKey` (default redacts to `***`) | + +**Examples:** + +```bash +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + settings --action list --json --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + settings --action set --key sourcePath --value ./tmp/mod_downloads --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + settings --action set --key managedDeploymentEnabled --value true --settings-dir ./tmp/modsync_settings +``` + +**Tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~SettingsCliTests"` + +--- + ### `set-nexus-api-key` Store and optionally validate a Nexus Mods API key. diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index d898b25e..db4a4d7e 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -689,6 +690,28 @@ public class HolopatcherOptions : BaseOptions public string Arguments { get; set; } } + [Verb("settings", HelpText = "Get, set, or list persisted settings.json values")] + public class SettingsOptions : BaseOptions + { + [Option('a', "action", Required = true, HelpText = "list|get|set")] + public string Action { get; set; } + + [Option('k', "key", Required = false, HelpText = "Setting key (required for get/set)")] + public string Key { get; set; } + + [Option("value", Required = false, HelpText = "Value for set (omit or empty to remove key). Supports true/false, numbers, and JSON literals.")] + public string Value { get; set; } + + [Option("settings-dir", Required = false, HelpText = "ModSync settings directory (default: AppData/ModSync)")] + public string SettingsDirectory { get; set; } + + [Option("json", Required = false, Default = false, HelpText = "Emit JSON for list/get/set results")] + public bool Json { get; set; } + + [Option("reveal-secrets", Required = false, Default = false, HelpText = "Include sensitive values such as nexusModsApiKey in list/get output")] + public bool RevealSecrets { get; set; } + } + public static int Run(string[] args) { // Disable keyring BEFORE any Python initialization to prevent pip hanging @@ -700,7 +723,7 @@ public static int Run(string[] args) var parser = new Parser(with => with.HelpWriter = Console.Out); - return parser.ParseArguments(args) + return parser.ParseArguments(args) .MapResult( (ConvertOptions opts) => RunConvertAsync(opts).GetAwaiter().GetResult(), (MergeOptions opts) => RunMergeAsync(opts).GetAwaiter().GetResult(), @@ -709,6 +732,7 @@ public static int Run(string[] args) (SetNexusApiKeyOptions opts) => RunSetNexusApiKeyAsync(opts).GetAwaiter().GetResult(), (InstallPythonDepsOptions opts) => RunInstallPythonDepsAsync(opts).GetAwaiter().GetResult(), (HolopatcherOptions opts) => RunHolopatcherAsync(opts).GetAwaiter().GetResult(), + (SettingsOptions opts) => RunSettingsAsync(opts), errs => 1); } @@ -3369,6 +3393,178 @@ await Logger.LogWarningAsync( } } + private static int RunSettingsAsync(SettingsOptions opts) + { + SetVerboseMode(opts.Verbose); + + try + { + string action = (opts.Action ?? string.Empty).Trim().ToLowerInvariant(); + string settingsDirectory = SettingsFileStore.ResolveSettingsDirectory(opts.SettingsDirectory); + + switch (action) + { + case "list": + return SettingsCliList(settingsDirectory, opts.Json, opts.RevealSecrets); + case "get": + return SettingsCliGet( + settingsDirectory, + RequireSettingsKey(opts.Key, "get"), + opts.Json, + opts.RevealSecrets); + case "set": + return SettingsCliSet( + settingsDirectory, + RequireSettingsKey(opts.Key, "set"), + opts.Value, + opts.Json); + default: + Logger.LogError($"Unknown settings action '{opts.Action}'. Use list|get|set."); + return 1; + } + } + catch (Exception ex) + { + Logger.LogError($"Settings command failed: {ex.Message}"); + if (opts.Verbose) + { + Logger.LogException(ex); + } + + return 1; + } + } + + [NotNull] + private static string RequireSettingsKey([CanBeNull] string key, [NotNull] string action) + { + if (!string.IsNullOrWhiteSpace(key)) + { + return key.Trim(); + } + + throw new ArgumentException($"Settings action '{action}' requires --key."); + } + + private static int SettingsCliList([NotNull] string settingsDirectory, bool json, bool revealSecrets) + { + JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory); + JsonObject outputRoot = SettingsFileStore.CloneForOutput(root, revealSecrets); + + if (json) + { + var payload = new + { + settingsDirectory, + settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory), + values = outputRoot, + }; + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + IReadOnlyList keys = SettingsFileStore.ListKeys(outputRoot); + if (keys.Count == 0) + { + Logger.LogAsync($"No settings found in {SettingsFileStore.ResolveSettingsFilePath(settingsDirectory)}").GetAwaiter().GetResult(); + return 0; + } + + Logger.LogAsync($"Settings ({keys.Count}):").GetAwaiter().GetResult(); + foreach (string key in keys) + { + string rendered = FormatSettingsValueForText(outputRoot[key]); + Logger.LogAsync($" {key}={rendered}").GetAwaiter().GetResult(); + } + + return 0; + } + + private static int SettingsCliGet( + [NotNull] string settingsDirectory, + [NotNull] string key, + bool json, + bool revealSecrets) + { + JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory); + if (!SettingsFileStore.TryGetValue(root, key, out JsonNode value)) + { + Logger.LogError($"Setting '{key}' was not found."); + return 1; + } + + if (!revealSecrets && SettingsFileStore.SensitiveKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + value = JsonValue.Create("***"); + } + + if (json) + { + var payload = new + { + key, + value, + settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory), + }; + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + Console.WriteLine(FormatSettingsValueForText(value)); + return 0; + } + + private static int SettingsCliSet( + [NotNull] string settingsDirectory, + [NotNull] string key, + [CanBeNull] string rawValue, + bool json) + { + JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory); + SettingsFileStore.SetValue(root, key, rawValue); + SettingsFileStore.SaveRoot(settingsDirectory, root); + + if (json) + { + JsonObject outputRoot = SettingsFileStore.CloneForOutput(root, revealSecrets: false); + var payload = new + { + key, + value = outputRoot.TryGetPropertyValue(key, out JsonNode node) ? node : null, + settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory), + }; + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + Logger.LogAsync($"Updated setting '{key}' in {SettingsFileStore.ResolveSettingsFilePath(settingsDirectory)}").GetAwaiter().GetResult(); + return 0; + } + + [NotNull] + private static string FormatSettingsValueForText([CanBeNull] JsonNode value) + { + if (value is null) + { + return string.Empty; + } + + if (value is JsonValue jsonValue) + { + if (jsonValue.TryGetValue(out string stringValue)) + { + return stringValue; + } + + if (jsonValue.TryGetValue(out bool boolValue)) + { + return boolValue ? "true" : "false"; + } + } + + return value.ToJsonString(); + } + private static async Task RunSetNexusApiKeyAsync(SetNexusApiKeyOptions opts) { SetVerboseMode(opts.Verbose); diff --git a/src/ModSync.Core/Services/Settings/SettingsFileStore.cs b/src/ModSync.Core/Services/Settings/SettingsFileStore.cs new file mode 100644 index 00000000..e14f15d4 --- /dev/null +++ b/src/ModSync.Core/Services/Settings/SettingsFileStore.cs @@ -0,0 +1,203 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +using JetBrains.Annotations; + +namespace ModSync.Core.Services.Settings +{ + /// + /// Generic read/write helpers for persisted settings.json without overwriting unrelated GUI-owned keys. + /// + public static class SettingsFileStore + { + [NotNull] + private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + }; + + [NotNull] + public static readonly IReadOnlyCollection SensitiveKeys = new[] + { + "nexusModsApiKey", + }; + + [NotNull] + public static string ResolveSettingsDirectory([CanBeNull] string settingsDirectory) + { + return string.IsNullOrWhiteSpace(settingsDirectory) + ? ModSyncSettings.GetSettingsDirectory() + : settingsDirectory.Trim(); + } + + [NotNull] + public static string ResolveSettingsFilePath([CanBeNull] string settingsDirectory) + { + string directory = ResolveSettingsDirectory(settingsDirectory); + string settingsPath = Path.Combine(directory, "settings.json"); + string legacySettingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "KOTORModSync", + "settings.json"); + + if (!File.Exists(settingsPath) && File.Exists(legacySettingsPath)) + { + return legacySettingsPath; + } + + return settingsPath; + } + + [NotNull] + public static JsonObject LoadRoot([CanBeNull] string settingsDirectory) + { + string settingsPath = ResolveSettingsFilePath(settingsDirectory); + if (!File.Exists(settingsPath)) + { + return new JsonObject(); + } + + try + { + return JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject() ?? new JsonObject(); + } + catch (Exception ex) + { + Logger.LogWarning($"[SettingsFileStore] Failed to parse settings file '{settingsPath}': {ex.Message}"); + return new JsonObject(); + } + } + + public static void SaveRoot([CanBeNull] string settingsDirectory, [NotNull] JsonObject root) + { + if (root is null) + { + throw new ArgumentNullException(nameof(root)); + } + + string settingsPath = ResolveSettingsFilePath(settingsDirectory); + string directory = Path.GetDirectoryName(settingsPath) + ?? throw new InvalidOperationException($"Could not determine directory for settings path '{settingsPath}'."); + if (!Directory.Exists(directory)) + { + _ = Directory.CreateDirectory(directory); + } + + File.WriteAllText(settingsPath, root.ToJsonString(s_jsonOptions)); + RestrictOwnerPermissions(settingsPath); + } + + [NotNull] + public static IReadOnlyList ListKeys([NotNull] JsonObject root) + { + return root.Select(pair => pair.Key).OrderBy(key => key, StringComparer.OrdinalIgnoreCase).ToList(); + } + + public static bool TryGetValue([NotNull] JsonObject root, [NotNull] string key, out JsonNode value) + { + if (root.TryGetPropertyValue(key, out JsonNode node)) + { + value = node?.DeepClone(); + return true; + } + + value = null; + return false; + } + + public static void SetValue([NotNull] JsonObject root, [NotNull] string key, [CanBeNull] string rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + root.Remove(key); + return; + } + + root[key] = ParseCliValue(rawValue); + } + + [NotNull] + public static JsonNode ParseCliValue([NotNull] string rawValue) + { + string trimmed = rawValue.Trim(); + if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)) + { + return JsonValue.Create(true); + } + + if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase)) + { + return JsonValue.Create(false); + } + + if (string.Equals(trimmed, "null", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (long.TryParse(trimmed, out long longValue)) + { + return JsonValue.Create(longValue); + } + + if (double.TryParse(trimmed, out double doubleValue)) + { + return JsonValue.Create(doubleValue); + } + + if ((trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal)) + || (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal))) + { + return JsonNode.Parse(trimmed)?.DeepClone(); + } + + return JsonValue.Create(trimmed); + } + + [NotNull] + public static JsonObject CloneForOutput([NotNull] JsonObject root, bool revealSecrets) + { + JsonObject clone = root.DeepClone()?.AsObject() ?? new JsonObject(); + if (revealSecrets) + { + return clone; + } + + foreach (string sensitiveKey in SensitiveKeys) + { + if (clone.TryGetPropertyValue(sensitiveKey, out JsonNode sensitiveValue) && sensitiveValue != null) + { + clone[sensitiveKey] = "***"; + } + } + + return clone; + } + + private static void RestrictOwnerPermissions([NotNull] string settingsPath) + { +#if NET7_0_OR_GREATER + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + File.SetUnixFileMode(settingsPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) + { + Logger.LogWarning($"[SettingsFileStore] Could not restrict file permissions on '{settingsPath}': {ex.Message}"); + } + } +#endif + } + } +} diff --git a/src/ModSync.Tests/SettingsCliTests.cs b/src/ModSync.Tests/SettingsCliTests.cs new file mode 100644 index 00000000..6d68128c --- /dev/null +++ b/src/ModSync.Tests/SettingsCliTests.cs @@ -0,0 +1,142 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; +using System.Text.Json; + +using ModSync.Core.CLI; +using ModSync.Core.Services.Settings; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class SettingsCliTests + { + private string _settingsDirectory; + + [SetUp] + public void SetUp() + { + _settingsDirectory = Path.Combine(Path.GetTempPath(), "ModSync_SettingsCli_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_settingsDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_settingsDirectory)) + { + Directory.Delete(_settingsDirectory, recursive: true); + } + } + + [Test] + public void SettingsCli_SetGetList_PreservesUnrelatedKeys() + { + string settingsPath = Path.Combine(_settingsDirectory, "settings.json"); + File.WriteAllText(settingsPath, "{\"theme\":\"/Styles/LightStyle.axaml\",\"debugLogging\":true}"); + + int setExit = ModBuildConverter.Run(new[] + { + "settings", + "--action", "set", + "--key", "managedDeploymentEnabled", + "--value", "true", + "--settings-dir", _settingsDirectory, + }); + Assert.That(setExit, Is.EqualTo(0)); + + int getExit = ModBuildConverter.Run(new[] + { + "settings", + "--action", "get", + "--key", "managedDeploymentEnabled", + "--settings-dir", _settingsDirectory, + }); + Assert.That(getExit, Is.EqualTo(0)); + + string json = File.ReadAllText(settingsPath); + Assert.That(json, Does.Contain("theme")); + Assert.That(json, Does.Contain("debugLogging")); + Assert.That(json, Does.Contain("managedDeploymentEnabled")); + + ModSyncSettings loaded = ModSyncSettings.LoadFromDirectory(_settingsDirectory); + Assert.That(loaded.ManagedDeploymentEnabled, Is.True); + } + + [Test] + public void SettingsCli_List_RedactsNexusApiKeyByDefault() + { + File.WriteAllText( + Path.Combine(_settingsDirectory, "settings.json"), + "{\"nexusModsApiKey\":\"super-secret\",\"sourcePath\":\"/tmp/mods\"}"); + + int exitCode = RunWithCapturedStdout(new[] + { + "settings", + "--action", "list", + "--json", + "--settings-dir", _settingsDirectory, + }, out string stdout); + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.EqualTo(0)); + Assert.That(stdout, Does.Not.Contain("super-secret")); + Assert.That(stdout, Does.Contain("***")); + }); + } + + [Test] + public void SettingsCli_Set_RemovesKeyWhenValueEmpty() + { + File.WriteAllText( + Path.Combine(_settingsDirectory, "settings.json"), + "{\"activeProfileName\":\"Old Profile\"}"); + + int exitCode = ModBuildConverter.Run(new[] + { + "settings", + "--action", "set", + "--key", "activeProfileName", + "--value", "", + "--settings-dir", _settingsDirectory, + }); + + Assert.That(exitCode, Is.EqualTo(0)); + ModSyncSettings loaded = ModSyncSettings.LoadFromDirectory(_settingsDirectory); + Assert.That(loaded.ActiveProfileName, Is.Null.Or.Empty); + } + + [Test] + public void SettingsFileStore_ParseCliValue_ParsesBooleansAndNumbers() + { + Assert.Multiple(() => + { + Assert.That(SettingsFileStore.ParseCliValue("true").GetValue(), Is.True); + Assert.That(SettingsFileStore.ParseCliValue("42").GetValue(), Is.EqualTo(42)); + Assert.That(SettingsFileStore.ParseCliValue("hello").GetValue(), Is.EqualTo("hello")); + }); + } + + private static int RunWithCapturedStdout(string[] args, out string stdout) + { + var writer = new StringWriter(); + TextWriter previousOut = Console.Out; + Console.SetOut(writer); + try + { + return ModBuildConverter.Run(args); + } + finally + { + Console.SetOut(previousOut); + stdout = writer.ToString(); + } + } + } +} From afe2416b2dd0bf365d96f0f46f031d235a389c03 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 24 Jul 2026 21:15:56 -0500 Subject: [PATCH 2/2] chore: sync local changes (2026-07-24) --- .cursor/hooks/state/continual-learning.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .cursor/hooks/state/continual-learning.json diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json new file mode 100644 index 00000000..3f8a92d0 --- /dev/null +++ b/.cursor/hooks/state/continual-learning.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "lastRunAtMs": 0, + "turnsSinceLastRun": 1, + "lastTranscriptMtimeMs": null, + "lastProcessedGenerationId": "3a831d78-3bfa-4ab1-9df9-5c1ebe9b056d", + "trialStartedAtMs": null +}