From 17e679b484dfb9499ee9e0e447f3c00b8268ad53 Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 21 Apr 2026 13:23:55 -0700 Subject: [PATCH 1/2] add cline-inspired workflow features --- README.md | 8 +- docs/permissions.md | 32 ++ docs/runtime.md | 14 + .../Abstractions/ISubAgentOrchestrator.cs | 23 ++ .../AgentsServiceCollectionExtensions.cs | 1 + .../Internal/SubAgentOrchestrator.cs | 166 ++++++++++ .../Internal/SubAgentToolContract.cs | 51 +++ .../Internal/ToolCallDispatcher.cs | 99 +++++- .../Models/AgentRunContext.cs | 4 +- .../Models/SubAgentBatchExecutionResult.cs | 13 + .../Services/AgentFrameworkBridge.cs | 26 +- .../CliServiceCollectionExtensions.cs | 5 + .../Terminal/ConsoleInvocationEnvironment.cs | 28 ++ .../Abstractions/ICliInvocationEnvironment.cs | 24 ++ .../Abstractions/IReplHost.cs | 3 +- .../CliCommandFactory.cs | 26 +- .../Handlers/ApprovalsSlashCommandHandler.cs | 73 +++++ .../Handlers/PromptCommandHandler.cs | 38 +-- .../Handlers/ReplCommandHandler.cs | 2 +- .../Handlers/WorktreeCommandHandler.cs | 195 ++++++++++++ .../Models/CommandExecutionContext.cs | 11 +- .../Options/ApprovalSettingsText.cs | 88 +++++ .../Options/GlobalCliOptions.cs | 46 ++- .../PromptInvocationService.cs | 96 ++++++ src/SharpClaw.Code.Commands/Repl/ReplHost.cs | 65 ++-- .../Repl/ReplInteractionState.cs | 6 + .../Abstractions/IGitWorkspaceService.cs | 21 ++ .../Models/GitWorkspaceSnapshot.cs | 18 +- .../Models/GitWorktreeModels.cs | 42 +++ .../Services/GitWorkspaceService.cs | 301 +++++++++++++++++- .../IAutoApprovalBudgetTracker.cs | 15 + .../Models/PermissionEvaluationContext.cs | 5 +- .../PermissionsServiceCollectionExtensions.cs | 1 + .../Services/AutoApprovalBudgetTracker.cs | 45 +++ .../Services/PermissionPolicyEngine.cs | 46 ++- .../Commands/RunPromptRequest.cs | 4 +- .../Commands/TurnExecutionResult.cs | 4 +- .../Models/ApprovalSettings.cs | 26 ++ .../Models/PlanModels.cs | 74 +++++ .../Models/SharpClawWorkflowMetadataKeys.cs | 24 ++ .../Models/SubAgentModels.cs | 49 +++ .../Operational/RuntimeStatusReport.cs | 3 + .../Serialization/ProtocolJsonContext.cs | 14 + .../Abstractions/IInstructionRuleService.cs | 17 + .../Abstractions/IPlanWorkflowService.cs | 29 ++ .../Abstractions/IRuntimeCommandService.cs | 4 +- .../Abstractions/ITodoService.cs | 11 + .../RuntimeServiceCollectionExtensions.cs | 2 + .../Context/InstructionRuleService.cs | 237 ++++++++++++++ .../Context/InstructionRuleSnapshot.cs | 21 ++ .../Context/PromptContextAssembler.cs | 42 ++- .../OperationalDiagnosticsCoordinator.cs | 26 ++ .../OperationalDiagnosticsInput.cs | 5 +- .../Orchestration/ConversationRuntime.cs | 132 +++++++- .../Prompts/PromptReferenceResolver.cs | 5 +- .../Turns/DefaultTurnRunner.cs | 3 +- .../Workflow/ApprovalSettingsResolver.cs | 70 ++++ .../Workflow/PlanWorkflowService.cs | 276 ++++++++++++++++ .../Workflow/TodoService.cs | 210 ++++++++++++ .../Execution/ToolExecutor.cs | 3 +- .../Models/ToolExecutionContext.cs | 11 +- .../Runtime/PlanModeWorkflowTests.cs | 148 +++++++++ .../Runtime/PromptContextAssemblyTests.cs | 47 ++- .../Runtime/PromptInteractivityFlowTests.cs | 28 ++ .../Runtime/SubAgentOrchestrationTests.cs | 133 ++++++++ .../Smoke/CliCommandSurfaceTests.cs | 4 + .../Agents/ToolCallDispatcherTests.cs | 62 +++- .../Commands/ModeAndCliOptionsTests.cs | 60 ++++ .../Commands/PromptInvocationServiceTests.cs | 169 ++++++++++ .../MemorySkillsGitServiceTests.cs | 71 +++++ .../OperationalReportsJsonTests.cs | 2 + .../PermissionPolicyEngineTests.cs | 61 +++- .../Protocol/ProtocolJsonContextTests.cs | 63 ++++ .../Runtime/PromptReferenceResolverTests.cs | 3 +- .../Tools/ToolRegistryAndExecutionTests.cs | 3 +- 75 files changed, 3694 insertions(+), 99 deletions(-) create mode 100644 src/SharpClaw.Code.Agents/Abstractions/ISubAgentOrchestrator.cs create mode 100644 src/SharpClaw.Code.Agents/Internal/SubAgentOrchestrator.cs create mode 100644 src/SharpClaw.Code.Agents/Internal/SubAgentToolContract.cs create mode 100644 src/SharpClaw.Code.Agents/Models/SubAgentBatchExecutionResult.cs create mode 100644 src/SharpClaw.Code.Cli/Terminal/ConsoleInvocationEnvironment.cs create mode 100644 src/SharpClaw.Code.Commands/Abstractions/ICliInvocationEnvironment.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/WorktreeCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Options/ApprovalSettingsText.cs create mode 100644 src/SharpClaw.Code.Commands/PromptInvocationService.cs create mode 100644 src/SharpClaw.Code.Git/Models/GitWorktreeModels.cs create mode 100644 src/SharpClaw.Code.Permissions/Abstractions/IAutoApprovalBudgetTracker.cs create mode 100644 src/SharpClaw.Code.Permissions/Services/AutoApprovalBudgetTracker.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/ApprovalSettings.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/PlanModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/SubAgentModels.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IInstructionRuleService.cs create mode 100644 src/SharpClaw.Code.Runtime/Abstractions/IPlanWorkflowService.cs create mode 100644 src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs create mode 100644 src/SharpClaw.Code.Runtime/Context/InstructionRuleSnapshot.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs create mode 100644 src/SharpClaw.Code.Runtime/Workflow/PlanWorkflowService.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/PlanModeWorkflowTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/SubAgentOrchestrationTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Commands/PromptInvocationServiceTests.cs diff --git a/README.md b/README.md index 02b732c..8ec660f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ dotnet run --project src/SharpClaw.Code.Cli -- repl # Run a one-shot prompt dotnet run --project src/SharpClaw.Code.Cli -- prompt "Summarize this workspace" +dotnet run --project src/SharpClaw.Code.Cli -- --auto-approve shell --auto-approve-budget 2 prompt "Check git status and summarize" # Inspect runtime health and status dotnet run --project src/SharpClaw.Code.Cli -- doctor @@ -105,12 +106,13 @@ Parity-oriented commands now include: - `unshare` / `/unshare` - `compact` / `/compact` - `serve` / `/serve` +- `worktree` / `/worktree` - `/sessions` as a friendlier alias over `/session list` Primary workflow modes: - `build`: normal coding-agent execution -- `plan`: analysis-first mode that blocks mutating tools +- `plan`: structured deep planning that blocks mutating tools and syncs planning-owned session todos - `spec`: generates Kiro-style spec artifacts under `docs/superpowers/specs/-/` ## Core Capabilities @@ -202,6 +204,8 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--cwd ` | Working directory; defaults to the current directory | | `--model ` | Model id or alias; `provider/model` forms are supported where configured | | `--permission-mode ` | `readOnly`, `workspaceWrite`, or `dangerFullAccess`; see [docs/permissions.md](docs/permissions.md) | +| `--auto-approve ` | Auto-approve specific elevated scopes such as `shell`, `network`, or `promptRead` | +| `--auto-approve-budget ` | Cap how many elevated operations may be auto-approved in the session | | `--output-format text\|json` | Human-readable or structured output | | `--primary-mode ` | Workflow bias for prompts: `build`, `plan`, or `spec` | | `--session ` | Reuse a specific SharpClaw session id for prompt execution | @@ -211,7 +215,7 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--storage-root ` | External root for host-managed durable runtime state | | `--session-store fileSystem\|sqlite` | Select the embedded session/event storage backend | -Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `tool-packages`, `acp`, `bridge`, and `version`. +Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `worktree`, `mcp`, `plugins`, `tool-packages`, `acp`, `bridge`, and `version`. ## Documentation Map diff --git a/docs/permissions.md b/docs/permissions.md index 7872a87..19abf33 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -12,6 +12,29 @@ Default: **`WorkspaceWrite`**. +## Bounded auto-approval + +The CLI and REPL now support finer-grained approval control without switching all the way to **`DangerFullAccess`**. + +- CLI: + - `--auto-approve shell,network` + - `--auto-approve-budget 3` +- REPL: + - `/approvals` + - `/approvals set shell,promptRead 2` + - `/approvals reset` + +`ApprovalSettings` flow through `RuntimeCommandContext`, `RunPromptRequest`, `ToolExecutionContext`, and `PermissionEvaluationContext`. + +Behavior: + +- matching scopes are auto-approved only when the current rule/mode path would otherwise ask for approval +- explicit deny rules still win +- remembered approvals still short-circuit before budget consumption +- when the configured auto-approve budget is exhausted, the engine falls back to the normal approval transport + +The auto-approve budget is process-local and session-scoped, similar to remembered approvals. + ## Policy engine **`PermissionPolicyEngine`** evaluates **`ToolExecutionRequest`** with **`PermissionEvaluationContext`** by running an ordered list of **`IPermissionRule`** instances: @@ -57,6 +80,15 @@ Authenticated approvals are tenant-bound. If the runtime host context carries `T When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. In embedded-host flows, the remembered approval remains scoped to the current session and tenant context. +### Auto-approve budget tracking + +**`IAutoApprovalBudgetTracker`** (**`AutoApprovalBudgetTracker`**) tracks how many elevated operations have been auto-approved for the current session/tenant key. + +When `ApprovalSettings.AutoApproveBudget` is set: + +- the first matching operations consume the budget and are auto-approved +- later matching operations are no longer auto-approved and go through the normal approval path + ## Tool execution context **`ToolExecutionContext`** (`src/SharpClaw.Code.Tools/Models/ToolExecutionContext.cs`) carries **`IsInteractive`** (default **true** on the record). Parity tests set **`interactive: true/false`** to exercise approval vs deny paths. diff --git a/docs/runtime.md b/docs/runtime.md index 90b7f66..a5b517d 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -53,6 +53,8 @@ Prompt references are resolved before provider execution. Outside-workspace file When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a structured output contract that requires the model to return machine-readable requirements, design, and task content. +When the effective **`PrimaryMode`** is **`Plan`**, the assembler now appends a deep-planning JSON contract that requires the model to return summary, assumptions, risks, next action, and task data. + Conversation history is rebuilt from persisted session events and truncated by token budget before being attached to the next provider request. Assistant history prefers the persisted final turn output and only falls back to streamed provider deltas when needed. Cross-session memory is sourced from: @@ -77,6 +79,17 @@ The runtime injects only compact recall text and index freshness metadata. Detai Each spec-mode prompt creates a fresh folder. If the same slug already exists, the runtime appends `-2`, `-3`, and so on instead of overwriting an existing spec set. +## Plan workflow + +**`IPlanWorkflowService`** handles the post-processing path for **`plan`** mode: + +- parses the model response as structured JSON +- persists the latest deep-plan summary and next action into session metadata +- synchronizes planning-owned session todos through **`ITodoService`** +- returns a structured **`PlanExecutionResult`** on the turn result contract + +Planning-managed todos are isolated by owner id (`deep-planning`) so manual session todos remain untouched. + ## Operational diagnostics **`OperationalDiagnosticsCoordinator`** runs injectable **`IOperationalCheck`** implementations: @@ -102,6 +115,7 @@ The parity layer adds several runtime-owned services: - **`IShareSessionService`** — creates and removes self-hosted share snapshots - **`IHookDispatcher`** — executes configured hook processes for turn/tool/share/server events and exposes hook inspection/testing - **`ITodoService`** — persists session and workspace todo items under session metadata and `.sharpclaw/tasks.json` +- deep plan mode also uses `ITodoService.SyncManagedSessionTodosAsync(...)` to reconcile planning-owned session tasks - **`IWorkspaceInsightsService`** — reconstructs durable usage, cost, and execution stats from persisted event logs These services are intentionally small and runtime-owned rather than separate orchestration subsystems. diff --git a/src/SharpClaw.Code.Agents/Abstractions/ISubAgentOrchestrator.cs b/src/SharpClaw.Code.Agents/Abstractions/ISubAgentOrchestrator.cs new file mode 100644 index 0000000..2ac84fe --- /dev/null +++ b/src/SharpClaw.Code.Agents/Abstractions/ISubAgentOrchestrator.cs @@ -0,0 +1,23 @@ +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Agents.Abstractions; + +/// +/// Executes bounded delegated subagent tasks on behalf of a parent agent tool call. +/// +public interface ISubAgentOrchestrator +{ + /// + /// Executes the supplied delegated tasks using the bounded subagent worker. + /// + /// The delegated task batch. + /// The parent tool execution context. + /// The cancellation token. + /// The batch execution result and emitted runtime events. + Task ExecuteAsync( + SubAgentBatchRequest request, + ToolExecutionContext context, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs index 629b9b0..bd11c6d 100644 --- a/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class AgentsServiceCollectionExtensions public static IServiceCollection AddSharpClawAgents(this IServiceCollection services) { services.AddOptions(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Agents/Internal/SubAgentOrchestrator.cs b/src/SharpClaw.Code.Agents/Internal/SubAgentOrchestrator.cs new file mode 100644 index 0000000..3bf386e --- /dev/null +++ b/src/SharpClaw.Code.Agents/Internal/SubAgentOrchestrator.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Agents; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Agents.Internal; + +/// +/// Executes delegated subagent tasks as bounded read-only child runs. +/// +public sealed class SubAgentOrchestrator( + IServiceProvider serviceProvider, + IToolExecutor toolExecutor, + ILogger logger) : ISubAgentOrchestrator +{ + /// + public async Task ExecuteAsync( + SubAgentBatchRequest request, + ToolExecutionContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(context); + + if (request.Tasks is not { Length: > 0 }) + { + throw new InvalidOperationException("The subagent request must include at least one task."); + } + + if (request.Tasks.Length > SubAgentToolContract.MaxTasks) + { + throw new InvalidOperationException($"The subagent request exceeds the limit of {SubAgentToolContract.MaxTasks} tasks."); + } + + var runs = request.Tasks + .Select((task, index) => ExecuteSingleAsync(task, index, context, cancellationToken)) + .ToArray(); + var completedRuns = await Task.WhenAll(runs).ConfigureAwait(false); + + var taskResults = completedRuns.Select(static run => run.TaskResult).ToArray(); + var events = completedRuns.SelectMany(static run => run.Events).ToArray(); + var result = new SubAgentBatchResult( + Tasks: taskResults, + CompletedCount: taskResults.Count(static task => task.Succeeded), + FailedCount: taskResults.Count(static task => !task.Succeeded)); + + return new SubAgentBatchExecutionResult(result, events); + } + + private async Task ExecuteSingleAsync( + SubAgentTaskRequest task, + int index, + ToolExecutionContext parentContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(task); + var goal = task.Goal?.Trim(); + var expectedOutput = task.ExpectedOutput?.Trim(); + if (string.IsNullOrWhiteSpace(goal) || string.IsNullOrWhiteSpace(expectedOutput)) + { + throw new InvalidOperationException("Each subagent task requires both goal and expectedOutput."); + } + + var taskId = $"subtask-{index + 1:D2}-{Guid.NewGuid():N}"; + var delegatedTask = new DelegatedTaskContract( + taskId, + goal, + expectedOutput, + NormalizeConstraints(task.Constraints)); + + try + { + var subAgentWorker = serviceProvider.GetRequiredService(); + var result = await subAgentWorker.RunAsync( + new AgentRunContext( + SessionId: parentContext.SessionId, + TurnId: parentContext.TurnId, + Prompt: goal, + WorkingDirectory: parentContext.WorkingDirectory, + Model: string.IsNullOrWhiteSpace(parentContext.Model) ? "default" : parentContext.Model!, + PermissionMode: PermissionMode.ReadOnly, + OutputFormat: OutputFormat.Text, + ToolExecutor: toolExecutor, + Metadata: BuildChildMetadata(parentContext), + ParentAgentId: parentContext.AgentId, + DelegatedTask: delegatedTask, + PrimaryMode: PrimaryMode.Plan, + ToolMutationRecorder: null, + ConversationHistory: null, + IsInteractive: false, + ApprovalSettings: ApprovalSettings.Empty), + cancellationToken).ConfigureAwait(false); + + return new SingleTaskExecutionResult( + new SubAgentTaskResult( + TaskId: taskId, + Goal: goal, + ExpectedOutput: expectedOutput, + Succeeded: true, + Output: string.IsNullOrWhiteSpace(result.Output) ? "(no output)" : result.Output.Trim(), + ErrorMessage: null, + AgentId: result.AgentId), + result.Events ?? []); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Delegated subagent task {TaskId} failed for session {SessionId}, turn {TurnId}.", + taskId, + parentContext.SessionId, + parentContext.TurnId); + + return new SingleTaskExecutionResult( + new SubAgentTaskResult( + TaskId: taskId, + Goal: goal, + ExpectedOutput: expectedOutput, + Succeeded: false, + Output: null, + ErrorMessage: exception.Message, + AgentId: SubAgentWorker.SubAgentId), + []); + } + } + + private static string[] NormalizeConstraints(string[]? constraints) + => constraints? + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray() + ?? []; + + private static Dictionary BuildChildMetadata(ToolExecutionContext parentContext) + { + var metadata = new Dictionary(StringComparer.Ordinal); + if (parentContext.Metadata is not null + && parentContext.Metadata.TryGetValue("provider", out var provider) + && !string.IsNullOrWhiteSpace(provider)) + { + metadata["provider"] = provider; + } + + metadata[SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = JsonSerializer.Serialize( + SubAgentToolContract.AllowedReadOnlyTools, + ProtocolJsonContext.Default.StringArray); + return metadata; + } + + private sealed record SingleTaskExecutionResult( + SubAgentTaskResult TaskResult, + IReadOnlyList Events); +} diff --git a/src/SharpClaw.Code.Agents/Internal/SubAgentToolContract.cs b/src/SharpClaw.Code.Agents/Internal/SubAgentToolContract.cs new file mode 100644 index 0000000..d227172 --- /dev/null +++ b/src/SharpClaw.Code.Agents/Internal/SubAgentToolContract.cs @@ -0,0 +1,51 @@ +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.BuiltIn; + +namespace SharpClaw.Code.Agents.Internal; + +internal static class SubAgentToolContract +{ + public const string ToolName = "use_subagents"; + public const int MaxTasks = 3; + + public static readonly string[] AllowedReadOnlyTools = + [ + ReadFileTool.ToolName, + GlobSearchTool.ToolName, + GrepSearchTool.ToolName, + WorkspaceSearchTool.ToolName, + SymbolSearchTool.ToolName, + ToolSearchTool.ToolName, + ]; + + public static readonly ProviderToolDefinition Definition = new( + ToolName, + "Delegate up to 3 bounded read-only repository investigation tasks to subagents. Use this for parallel codebase research, not for edits or shell commands.", + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "tasks": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "goal": { "type": "string" }, + "expectedOutput": { "type": "string" }, + "constraints": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["goal", "expectedOutput"] + } + } + }, + "required": ["tasks"] + } + """); +} diff --git a/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs index f2b3d6b..5fa38a6 100644 --- a/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs +++ b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs @@ -1,5 +1,9 @@ +using System.Text.Json; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Tools.Abstractions; using SharpClaw.Code.Tools.Models; @@ -10,7 +14,8 @@ namespace SharpClaw.Code.Agents.Internal; /// and returns content blocks for the provider conversation. /// public sealed class ToolCallDispatcher( - IToolExecutor toolExecutor) + IToolExecutor toolExecutor, + ISubAgentOrchestrator subAgentOrchestrator) { /// /// Executes a tool call and returns a tool-result content block. @@ -36,6 +41,11 @@ public sealed class ToolCallDispatcher( var toolInputJson = toolUseEvent.ToolInputJson ?? "{}"; var toolUseId = toolUseEvent.ToolUseId; + if (string.Equals(toolName, SubAgentToolContract.ToolName, StringComparison.OrdinalIgnoreCase)) + { + return await DispatchSubAgentsAsync(toolUseEvent, toolUseId, toolInputJson, context, cancellationToken).ConfigureAwait(false); + } + // Execute the tool — ToolExecutor already publishes ToolStartedEvent and ToolCompletedEvent // via IRuntimeEventPublisher, so we do NOT re-publish here to avoid duplicates. var envelope = await toolExecutor.ExecuteAsync(toolName, toolInputJson, context, cancellationToken); @@ -66,4 +76,91 @@ public sealed class ToolCallDispatcher( // Events are already published by ToolExecutor; return empty list to avoid duplicates. return (resultBlock, envelope.Result, []); } + + private async Task<(ContentBlock ResultBlock, ToolResult ToolResult, List Events)> DispatchSubAgentsAsync( + ProviderEvent toolUseEvent, + string toolUseId, + string toolInputJson, + ToolExecutionContext context, + CancellationToken cancellationToken) + { + SubAgentBatchExecutionResult execution; + try + { + var request = JsonSerializer.Deserialize(toolInputJson, ProtocolJsonContext.Default.SubAgentBatchRequest) + ?? throw new InvalidOperationException("Subagent tool input was empty."); + execution = await subAgentOrchestrator.ExecuteAsync(request, context, cancellationToken).ConfigureAwait(false); + } + catch (JsonException exception) + { + return CreateSubAgentFailure(toolUseEvent, toolUseId, $"Invalid subagent request JSON: {exception.Message}"); + } + catch (InvalidOperationException exception) + { + return CreateSubAgentFailure(toolUseEvent, toolUseId, exception.Message); + } + + var textOutput = FormatSubAgentOutput(execution.Result); + var payloadJson = JsonSerializer.Serialize(execution.Result, ProtocolJsonContext.Default.SubAgentBatchResult); + var succeeded = execution.Result.CompletedCount > 0; + var toolResult = new ToolResult( + RequestId: toolUseEvent.RequestId, + ToolName: SubAgentToolContract.ToolName, + Succeeded: succeeded, + OutputFormat: Protocol.Enums.OutputFormat.Text, + Output: textOutput, + ErrorMessage: succeeded ? null : "All delegated subagent tasks failed.", + ExitCode: succeeded ? 0 : 1, + DurationMilliseconds: null, + StructuredOutputJson: payloadJson); + + var resultBlock = new ContentBlock( + ContentBlockKind.ToolResult, + succeeded ? textOutput : toolResult.ErrorMessage, + toolUseId, + null, + null, + succeeded ? null : true); + + return (resultBlock, toolResult, [.. execution.Events]); + } + + private static (ContentBlock ResultBlock, ToolResult ToolResult, List Events) CreateSubAgentFailure( + ProviderEvent toolUseEvent, + string toolUseId, + string message) + { + var result = new ToolResult( + RequestId: toolUseEvent.RequestId, + ToolName: SubAgentToolContract.ToolName, + Succeeded: false, + OutputFormat: Protocol.Enums.OutputFormat.Text, + Output: null, + ErrorMessage: message, + ExitCode: 1, + DurationMilliseconds: null, + StructuredOutputJson: null); + var block = new ContentBlock(ContentBlockKind.ToolResult, message, toolUseId, null, null, true); + return (block, result, []); + } + + private static string FormatSubAgentOutput(SubAgentBatchResult result) + { + var lines = new List + { + $"Delegated tasks completed: {result.CompletedCount} succeeded, {result.FailedCount} failed." + }; + + foreach (var task in result.Tasks) + { + lines.Add(string.Empty); + lines.Add($"Task {task.TaskId}: {task.Goal}"); + lines.Add($"Expected output: {task.ExpectedOutput}"); + lines.Add(task.Succeeded + ? $"Result: {task.Output}" + : $"Error: {task.ErrorMessage}"); + } + + return string.Join(Environment.NewLine, lines); + } } diff --git a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs index c1955b0..b5e36d4 100644 --- a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs +++ b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs @@ -25,6 +25,7 @@ namespace SharpClaw.Code.Agents.Models; /// to the provider request so the model has multi-turn context. /// /// Whether tool approvals can interact with the caller. +/// Optional bounded auto-approval settings forwarded to tool execution. public sealed record AgentRunContext( string SessionId, string TurnId, @@ -40,4 +41,5 @@ public sealed record AgentRunContext( PrimaryMode PrimaryMode = PrimaryMode.Build, IToolMutationRecorder? ToolMutationRecorder = null, IReadOnlyList? ConversationHistory = null, - bool IsInteractive = true); + bool IsInteractive = true, + ApprovalSettings? ApprovalSettings = null); diff --git a/src/SharpClaw.Code.Agents/Models/SubAgentBatchExecutionResult.cs b/src/SharpClaw.Code.Agents/Models/SubAgentBatchExecutionResult.cs new file mode 100644 index 0000000..a27b4c5 --- /dev/null +++ b/src/SharpClaw.Code.Agents/Models/SubAgentBatchExecutionResult.cs @@ -0,0 +1,13 @@ +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Agents.Models; + +/// +/// Carries the batch result and runtime events emitted by delegated subagent execution. +/// +/// The batch execution payload. +/// Runtime events emitted by delegated agent runs. +public sealed record SubAgentBatchExecutionResult( + SubAgentBatchResult Result, + IReadOnlyList Events); diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs index 25d23f6..2cd6787 100644 --- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs +++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs @@ -42,6 +42,9 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel PermissionMode: request.Context.PermissionMode, OutputFormat: request.Context.OutputFormat, EnvironmentVariables: null, + Model: request.Context.Model, + AgentId: request.AgentId, + Metadata: request.Context.Metadata, AllowedTools: allowedTools, AllowDangerousBypass: false, IsInteractive: request.Context.IsInteractive, @@ -54,7 +57,8 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel TrustedPluginNames: null, TrustedMcpServerNames: null, PrimaryMode: request.Context.PrimaryMode, - MutationRecorder: request.Context.ToolMutationRecorder); + MutationRecorder: request.Context.ToolMutationRecorder, + ApprovalSettings: request.Context.ApprovalSettings); // Map tool definitions from the registry to provider tool definitions var registryTools = await toolRegistry.ListAsync( @@ -64,6 +68,7 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel var providerTools = FilterAdvertisedTools(registryTools, allowedTools) .Select(t => new ProviderToolDefinition(t.Name, t.Description, t.InputSchemaJson)) .ToList(); + AppendSyntheticSubAgentTool(providerTools, allowedTools); ProviderInvocationResult? providerResult = null; var frameworkAgent = new SharpClawFrameworkAgent( @@ -174,4 +179,23 @@ private static IEnumerable FilterAdvertisedTools( return registryTools.Where(tool => allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase)); } + + private static void AppendSyntheticSubAgentTool( + ICollection providerTools, + IReadOnlyCollection? allowedTools) + { + if (providerTools.Any(tool => string.Equals(tool.Name, SubAgentToolContract.ToolName, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + if (allowedTools is not null + && allowedTools.Count > 0 + && !allowedTools.Contains(SubAgentToolContract.ToolName, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + providerTools.Add(SubAgentToolContract.Definition); + } } diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index d54fac9..ce46f48 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -23,6 +23,8 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -48,6 +50,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -75,7 +78,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Cli/Terminal/ConsoleInvocationEnvironment.cs b/src/SharpClaw.Code.Cli/Terminal/ConsoleInvocationEnvironment.cs new file mode 100644 index 0000000..2411ed5 --- /dev/null +++ b/src/SharpClaw.Code.Cli/Terminal/ConsoleInvocationEnvironment.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Commands; + +namespace SharpClaw.Code.Cli.Terminal; + +/// +/// Reads CLI invocation characteristics from the current process console streams. +/// +public sealed class ConsoleInvocationEnvironment : ICliInvocationEnvironment +{ + /// + public bool IsInputRedirected => Console.IsInputRedirected; + + /// + public bool IsOutputRedirected => Console.IsOutputRedirected; + + /// + public async Task ReadStandardInputToEndAsync(CancellationToken cancellationToken) + { + var readTask = Console.In.ReadToEndAsync(); + var completed = await Task.WhenAny(readTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); + if (completed != readTask) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + return await readTask.ConfigureAwait(false); + } +} diff --git a/src/SharpClaw.Code.Commands/Abstractions/ICliInvocationEnvironment.cs b/src/SharpClaw.Code.Commands/Abstractions/ICliInvocationEnvironment.cs new file mode 100644 index 0000000..1a26885 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Abstractions/ICliInvocationEnvironment.cs @@ -0,0 +1,24 @@ +namespace SharpClaw.Code.Commands; + +/// +/// Describes standard-input and standard-output characteristics for CLI invocation routing. +/// +public interface ICliInvocationEnvironment +{ + /// + /// Gets a value indicating whether standard input is redirected. + /// + bool IsInputRedirected { get; } + + /// + /// Gets a value indicating whether standard output is redirected. + /// + bool IsOutputRedirected { get; } + + /// + /// Reads all available standard input content. + /// + /// The cancellation token. + /// The complete standard input payload. + Task ReadStandardInputToEndAsync(CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Commands/Abstractions/IReplHost.cs b/src/SharpClaw.Code.Commands/Abstractions/IReplHost.cs index 7babf44..c88c2f3 100644 --- a/src/SharpClaw.Code.Commands/Abstractions/IReplHost.cs +++ b/src/SharpClaw.Code.Commands/Abstractions/IReplHost.cs @@ -11,7 +11,8 @@ public interface IReplHost /// Runs the REPL loop using the specified execution context. /// /// The execution context. + /// Optional initial prompt to execute before entering the loop. /// The cancellation token. /// The process exit code. - Task RunAsync(CommandExecutionContext context, CancellationToken cancellationToken); + Task RunAsync(CommandExecutionContext context, string? initialPrompt = null, CancellationToken cancellationToken = default); } diff --git a/src/SharpClaw.Code.Commands/CliCommandFactory.cs b/src/SharpClaw.Code.Commands/CliCommandFactory.cs index aaf730c..de72314 100644 --- a/src/SharpClaw.Code.Commands/CliCommandFactory.cs +++ b/src/SharpClaw.Code.Commands/CliCommandFactory.cs @@ -17,6 +17,8 @@ public sealed class CliCommandFactory( GlobalCliOptions globalCliOptions, IReplHost replHost, ReplCommandHandler replCommandHandler, + ICliInvocationEnvironment cliInvocationEnvironment, + PromptInvocationService promptInvocationService, ICustomCommandDiscoveryService customCommandDiscovery, IPathService pathService, IRuntimeCommandService runtimeCommandService, @@ -29,11 +31,17 @@ public sealed class CliCommandFactory( public async Task CreateRootCommandAsync(CancellationToken cancellationToken = default) { var rootCommand = new RootCommand("SharpClaw Code CLI. Starts interactive mode when no command is supplied."); + var rootPromptArgument = new Argument("text") + { + Description = "Optional initial prompt text. When stdin is redirected, SharpClaw runs a one-shot prompt instead of entering the REPL.", + Arity = ArgumentArity.ZeroOrMore, + }; foreach (var option in globalCliOptions.All) { rootCommand.Options.Add(option); } + rootCommand.Arguments.Add(rootPromptArgument); foreach (var handler in commandRegistry.GetCommandHandlers()) { @@ -43,7 +51,23 @@ public async Task CreateRootCommandAsync(CancellationToken cancella await AddDiscoveredCustomCommandsAsync(rootCommand, cancellationToken).ConfigureAwait(false); rootCommand.Subcommands.Add(replCommandHandler.BuildCommand(globalCliOptions)); - rootCommand.SetAction((parseResult, ct) => replHost.RunAsync(globalCliOptions.Resolve(parseResult), ct)); + rootCommand.SetAction((parseResult, ct) => + { + var context = globalCliOptions.Resolve(parseResult); + var promptTokens = parseResult.GetValue(rootPromptArgument) ?? []; + var shouldRunHeadless = cliInvocationEnvironment.IsInputRedirected + || cliInvocationEnvironment.IsOutputRedirected + || parseResult.GetValue(globalCliOptions.YoloOption) + || context.OutputFormat == Protocol.Enums.OutputFormat.Json; + + if (shouldRunHeadless) + { + return promptInvocationService.ExecuteAsync(promptTokens, context, forceNonInteractive: true, ct); + } + + var initialPrompt = string.Join(' ', promptTokens).Trim(); + return replHost.RunAsync(context, string.IsNullOrWhiteSpace(initialPrompt) ? null : initialPrompt, ct); + }); return rootCommand; } diff --git a/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs new file mode 100644 index 0000000..fcb01a0 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs @@ -0,0 +1,73 @@ +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// Shows or adjusts bounded auto-approval settings for REPL-driven prompts. +/// +public sealed class ApprovalsSlashCommandHandler( + ReplInteractionState replState, + OutputRendererDispatcher outputRendererDispatcher) : ISlashCommandHandler +{ + /// + public string CommandName => "approvals"; + + /// + public string Description => "Shows or sets auto-approval scopes and budget for REPL prompts."; + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0) + { + var effective = replState.ApprovalSettingsOverride ?? context.ApprovalSettings; + return RenderAsync( + $"Auto-approvals: {ApprovalSettingsText.RenderSummary(effective)} (override: {(replState.ApprovalSettingsOverride is null ? "none" : ApprovalSettingsText.RenderSummary(replState.ApprovalSettingsOverride))}).", + context, + cancellationToken); + } + + if (string.Equals(command.Arguments[0], "reset", StringComparison.OrdinalIgnoreCase) + || string.Equals(command.Arguments[0], "clear", StringComparison.OrdinalIgnoreCase)) + { + replState.ApprovalSettingsOverride = ApprovalSettings.Empty; + return RenderAsync("Auto-approval reset for the next prompt.", context, cancellationToken); + } + + if (!string.Equals(command.Arguments[0], "set", StringComparison.OrdinalIgnoreCase) || command.Arguments.Length < 2) + { + return RenderAsync("Usage: /approvals [set [budget]|reset]", context, cancellationToken, success: false); + } + + var budget = command.Arguments.Length >= 3 + ? ParseBudget(command.Arguments[2]) + : null; + var settings = ApprovalSettingsText.Parse(command.Arguments[1], budget) ?? ApprovalSettings.Empty; + replState.ApprovalSettingsOverride = settings; + return RenderAsync( + $"Auto-approval override set to {ApprovalSettingsText.RenderSummary(settings)}.", + context, + cancellationToken); + } + + private async Task RenderAsync( + string message, + CommandExecutionContext context, + CancellationToken cancellationToken, + bool success = true) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return success ? 0 : 1; + } + + private static int? ParseBudget(string value) + => int.TryParse(value, out var parsed) && parsed > 0 + ? parsed + : throw new InvalidOperationException($"Invalid auto-approve budget '{value}'."); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs index 3f22474..f25131c 100644 --- a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs @@ -12,8 +12,7 @@ namespace SharpClaw.Code.Commands; /// Implements the prompt command. /// public sealed class PromptCommandHandler( - IRuntimeCommandService runtimeCommandService, - OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler + PromptInvocationService promptInvocationService) : ICommandHandler { /// public string Name => "prompt"; @@ -27,37 +26,18 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var command = new Command(Name, Description); var promptArgument = new Argument("text") { - Description = "The prompt text to execute." + Description = "The prompt text to execute. When omitted, stdin is used if redirected.", + Arity = ArgumentArity.ZeroOrMore, }; command.Arguments.Add(promptArgument); - command.SetAction(async (parseResult, cancellationToken) => - { - var prompt = string.Join(' ', parseResult.GetValue(promptArgument) ?? []).Trim(); - var context = globalOptions.Resolve(parseResult); - try - { - var result = await runtimeCommandService.ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(), cancellationToken); - await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken); - return 0; - } - catch (ProviderExecutionException exception) - { - await outputRendererDispatcher.RenderCommandResultAsync( - CreateProviderFailureResult(exception, context.OutputFormat), - context.OutputFormat, - cancellationToken); - return 1; - } - }); + command.SetAction((parseResult, cancellationToken) => + promptInvocationService.ExecuteAsync( + parseResult.GetValue(promptArgument) ?? [], + globalOptions.Resolve(parseResult), + forceNonInteractive: false, + cancellationToken)); return command; } - private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) - => new( - Succeeded: false, - ExitCode: 1, - OutputFormat: outputFormat, - Message: $"Provider failure ({exception.Kind}): {exception.Message}", - DataJson: null); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ReplCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ReplCommandHandler.cs index c3169b4..7993b06 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ReplCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ReplCommandHandler.cs @@ -16,7 +16,7 @@ public sealed class ReplCommandHandler(IReplHost replHost) public Command BuildCommand(GlobalCliOptions globalOptions) { var command = new Command("repl", "Starts the interactive REPL shell."); - command.SetAction((parseResult, cancellationToken) => replHost.RunAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.SetAction((parseResult, cancellationToken) => replHost.RunAsync(globalOptions.Resolve(parseResult), cancellationToken: cancellationToken)); return command; } } diff --git a/src/SharpClaw.Code.Commands/Handlers/WorktreeCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/WorktreeCommandHandler.cs new file mode 100644 index 0000000..e8d0c5d --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/WorktreeCommandHandler.cs @@ -0,0 +1,195 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Git.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists, creates, and prunes git worktrees for the current repository. +/// +public sealed class WorktreeCommandHandler( + IGitWorkspaceService gitWorkspaceService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + public string Name => "worktree"; + + /// + public string Description => "Lists, creates, and prunes git worktrees."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildAddCommand(globalOptions)); + command.Subcommands.Add(BuildPruneCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteListAsync(context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "add", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + var startPoint = command.Arguments.Length >= 4 ? command.Arguments[3] : null; + return ExecuteAddAsync(command.Arguments[1], command.Arguments[2], startPoint, useExistingBranch: false, context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "attach", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + return ExecuteAddAsync(command.Arguments[1], command.Arguments[2], null, useExistingBranch: true, context, cancellationToken); + } + + if (string.Equals(command.Arguments[0], "prune", StringComparison.OrdinalIgnoreCase)) + { + return ExecutePruneAsync(context, cancellationToken); + } + + return RenderAsync( + "Usage: /worktree [list|add [startPoint]|attach |prune]", + null, + context, + success: false, + cancellationToken: cancellationToken); + } + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists git worktrees."); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildAddCommand(GlobalCliOptions globalOptions) + { + var command = new Command("add", "Creates a git worktree."); + var pathOption = new Option("--path") { Required = true, Description = "Path for the new worktree." }; + var branchOption = new Option("--branch") { Required = true, Description = "Branch name to create or attach." }; + var startPointOption = new Option("--start-point") { Description = "Optional starting ref for a new branch." }; + var useExistingBranchOption = new Option("--use-existing-branch") + { + Description = "Attach an existing branch instead of creating a new one.", + DefaultValueFactory = _ => false, + }; + command.Options.Add(pathOption); + command.Options.Add(branchOption); + command.Options.Add(startPointOption); + command.Options.Add(useExistingBranchOption); + command.SetAction((parseResult, cancellationToken) => ExecuteAddAsync( + parseResult.GetValue(pathOption)!, + parseResult.GetValue(branchOption)!, + parseResult.GetValue(startPointOption), + parseResult.GetValue(useExistingBranchOption), + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + + private Command BuildPruneCommand(GlobalCliOptions globalOptions) + { + var command = new Command("prune", "Prunes stale git worktree administrative state."); + command.SetAction((parseResult, cancellationToken) => ExecutePruneAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + try + { + var payload = await gitWorkspaceService.ListWorktreesAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + $"{payload.Worktrees.Count} worktree(s).", + JsonSerializer.Serialize(payload, JsonOptions), + context, + success: true, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + return await RenderAsync(exception.Message, null, context, success: false, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ExecuteAddAsync( + string path, + string branch, + string? startPoint, + bool useExistingBranch, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + if (context.PermissionMode == PermissionMode.ReadOnly) + { + return await RenderAsync("Read-only mode blocks git worktree creation.", null, context, success: false, cancellationToken).ConfigureAwait(false); + } + + try + { + var payload = await gitWorkspaceService + .CreateWorktreeAsync(context.WorkingDirectory, path, branch, startPoint, useExistingBranch, cancellationToken) + .ConfigureAwait(false); + var message = useExistingBranch + ? $"Attached worktree '{payload.Worktree.Path}' to existing branch '{payload.Worktree.Branch ?? branch}'." + : $"Created worktree '{payload.Worktree.Path}' on branch '{payload.Worktree.Branch ?? branch}'."; + return await RenderAsync(message, JsonSerializer.Serialize(payload, JsonOptions), context, success: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + return await RenderAsync(exception.Message, null, context, success: false, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ExecutePruneAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + if (context.PermissionMode == PermissionMode.ReadOnly) + { + return await RenderAsync("Read-only mode blocks git worktree pruning.", null, context, success: false, cancellationToken).ConfigureAwait(false); + } + + try + { + var payload = await gitWorkspaceService.PruneWorktreesAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + $"Pruned {payload.PrunedCount} stale worktree record(s).", + JsonSerializer.Serialize(payload, JsonOptions), + context, + success: true, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + return await RenderAsync(exception.Message, null, context, success: false, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RenderAsync( + string message, + string? dataJson, + CommandExecutionContext context, + bool success, + CancellationToken cancellationToken) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(success, success ? 0 : 1, context.OutputFormat, message, dataJson), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return success ? 0 : 1; + } +} diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs index 8b7a39e..c9a6038 100644 --- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs +++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs @@ -15,6 +15,7 @@ namespace SharpClaw.Code.Commands.Models; /// Optional explicit session id for prompts and session-scoped commands. /// Optional explicit agent id for prompt execution. /// Optional embedded-host identity and tenant/storage context. +/// Optional bounded auto-approval settings. public sealed record CommandExecutionContext( string WorkingDirectory, string? Model, @@ -23,7 +24,8 @@ public sealed record CommandExecutionContext( PrimaryMode PrimaryMode, string? SessionId = null, string? AgentId = null, - RuntimeHostContext? HostContext = null) + RuntimeHostContext? HostContext = null, + ApprovalSettings? ApprovalSettings = null) { /// /// Converts the CLI command context into the runtime invocation context. @@ -31,11 +33,13 @@ public sealed record CommandExecutionContext( /// Whether the current caller can participate in approval prompts. /// Optional primary-mode override. /// Optional agent id override. + /// Optional bounded auto-approval override. /// The runtime command context. public RuntimeCommandContext ToRuntimeCommandContext( bool isInteractive = true, PrimaryMode? primaryModeOverride = null, - string? agentIdOverride = null) + string? agentIdOverride = null, + ApprovalSettings? approvalSettingsOverride = null) => new( WorkingDirectory, Model, @@ -45,5 +49,6 @@ public RuntimeCommandContext ToRuntimeCommandContext( SessionId, agentIdOverride ?? AgentId, isInteractive, - HostContext); + HostContext, + approvalSettingsOverride ?? ApprovalSettings); } diff --git a/src/SharpClaw.Code.Commands/Options/ApprovalSettingsText.cs b/src/SharpClaw.Code.Commands/Options/ApprovalSettingsText.cs new file mode 100644 index 0000000..37b8632 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Options/ApprovalSettingsText.cs @@ -0,0 +1,88 @@ +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Commands.Options; + +internal static class ApprovalSettingsText +{ + private static readonly ApprovalScope[] AllScopes = + [ + ApprovalScope.ToolExecution, + ApprovalScope.FileSystemWrite, + ApprovalScope.ShellExecution, + ApprovalScope.NetworkAccess, + ApprovalScope.SessionOperation, + ApprovalScope.PromptOutsideWorkspaceRead, + ]; + + public static ApprovalSettings? Parse(string? scopesText, int? budget) + { + var scopes = ParseScopes(scopesText); + if (scopes is null && budget is null) + { + return null; + } + + var normalizedBudget = budget is > 0 ? budget : null; + return new ApprovalSettings(scopes ?? [], normalizedBudget); + } + + public static IReadOnlyList? ParseScopes(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var tokens = value + .Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static token => token.Trim().ToLowerInvariant()) + .ToArray(); + if (tokens.Length == 0) + { + return []; + } + + if (tokens.Contains("none")) + { + return []; + } + + var scopes = new List(); + foreach (var token in tokens) + { + if (string.Equals(token, "all", StringComparison.Ordinal)) + { + scopes.AddRange(AllScopes); + continue; + } + + scopes.Add(token switch + { + "tool" or "toolexecution" => ApprovalScope.ToolExecution, + "file" or "write" or "filesystemwrite" => ApprovalScope.FileSystemWrite, + "shell" or "shellexecution" => ApprovalScope.ShellExecution, + "network" or "networkaccess" => ApprovalScope.NetworkAccess, + "session" or "sessionoperation" => ApprovalScope.SessionOperation, + "promptread" or "promptoutsideworkspaceread" => ApprovalScope.PromptOutsideWorkspaceRead, + _ => throw new InvalidOperationException( + $"Unknown auto-approval scope '{token}'. Supported values: all, none, tool, file, shell, network, session, promptRead.") + }); + } + + return scopes.Distinct().OrderBy(static scope => scope).ToArray(); + } + + public static string RenderSummary(ApprovalSettings? settings) + { + if (settings is null || settings.AutoApproveScopes.Count == 0) + { + return "none"; + } + + var scopes = string.Join(", ", settings.AutoApproveScopes); + return settings.AutoApproveBudget is { } budget + ? $"{scopes} (budget {budget})" + : scopes; + } +} diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs index ff84de4..07d3470 100644 --- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs +++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs @@ -42,6 +42,25 @@ public GlobalCliOptions() Recursive = true }; + AutoApproveOption = new Option("--auto-approve") + { + Description = "Comma-separated approval scopes to auto-approve: tool,file,shell,network,session,promptRead,all,none.", + Recursive = true + }; + + AutoApproveBudgetOption = new Option("--auto-approve-budget") + { + Description = "Optional session budget for auto-approved elevated operations.", + Recursive = true + }; + + YoloOption = new Option("--yolo") + { + Description = "Forces dangerFullAccess for one-shot/headless execution and suppresses approval prompts.", + Recursive = true + }; + YoloOption.Aliases.Add("-y"); + PrimaryModeOption = new Option("--primary-mode") { Description = "Sets the primary workflow mode: build, plan, or spec.", @@ -106,11 +125,26 @@ public GlobalCliOptions() /// public Option PermissionModeOption { get; } + /// + /// Gets the auto-approve scopes option. + /// + public Option AutoApproveOption { get; } + + /// + /// Gets the auto-approve budget option. + /// + public Option AutoApproveBudgetOption { get; } + /// /// Gets the primary workflow mode option. /// public Option PrimaryModeOption { get; } + /// + /// Gets the yolo option. + /// + public Option YoloOption { get; } + /// /// Gets the optional session id option. /// @@ -150,6 +184,9 @@ public GlobalCliOptions() WorkingDirectoryOption, ModelOption, PermissionModeOption, + AutoApproveOption, + AutoApproveBudgetOption, + YoloOption, PrimaryModeOption, SessionOption, AgentOption, @@ -168,6 +205,9 @@ public CommandExecutionContext Resolve(ParseResult parseResult) { var outputFormatText = parseResult.GetValue(OutputFormatOption) ?? "text"; var permissionModeText = parseResult.GetValue(PermissionModeOption) ?? "workspaceWrite"; + var autoApproveText = parseResult.GetValue(AutoApproveOption); + var autoApproveBudget = parseResult.GetValue(AutoApproveBudgetOption); + var yolo = parseResult.GetValue(YoloOption); var cwd = parseResult.GetValue(WorkingDirectoryOption); var resolvedWorkingDirectory = string.IsNullOrWhiteSpace(cwd) ? Environment.CurrentDirectory @@ -178,16 +218,18 @@ public CommandExecutionContext Resolve(ParseResult parseResult) var storageRoot = parseResult.GetValue(StorageRootOption); var sessionStoreText = parseResult.GetValue(SessionStoreOption); var hostContext = CreateHostContext(hostId, tenantId, storageRoot, sessionStoreText); + var approvalSettings = ApprovalSettingsText.Parse(autoApproveText, autoApproveBudget); return new CommandExecutionContext( WorkingDirectory: resolvedWorkingDirectory, Model: parseResult.GetValue(ModelOption), - PermissionMode: ParsePermissionMode(permissionModeText), + PermissionMode: yolo ? PermissionMode.DangerFullAccess : ParsePermissionMode(permissionModeText), OutputFormat: ParseOutputFormat(outputFormatText), PrimaryMode: ParsePrimaryMode(primaryText), SessionId: parseResult.GetValue(SessionOption), AgentId: parseResult.GetValue(AgentOption), - HostContext: hostContext); + HostContext: hostContext, + ApprovalSettings: approvalSettings); } private static RuntimeHostContext? CreateHostContext( diff --git a/src/SharpClaw.Code.Commands/PromptInvocationService.cs b/src/SharpClaw.Code.Commands/PromptInvocationService.cs new file mode 100644 index 0000000..a9c36d8 --- /dev/null +++ b/src/SharpClaw.Code.Commands/PromptInvocationService.cs @@ -0,0 +1,96 @@ +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Executes one-shot prompt invocations, including piped stdin composition and headless routing. +/// +public sealed class PromptInvocationService( + IRuntimeCommandService runtimeCommandService, + OutputRendererDispatcher outputRendererDispatcher, + ICliInvocationEnvironment cliInvocationEnvironment) +{ + /// + /// Executes a prompt built from CLI arguments and optional piped stdin. + /// + /// Prompt tokens supplied on the CLI. + /// The command execution context. + /// Whether the invocation must suppress approval prompts. + /// The cancellation token. + /// The process exit code. + public async Task ExecuteAsync( + IReadOnlyList promptTokens, + CommandExecutionContext context, + bool forceNonInteractive, + CancellationToken cancellationToken) + { + var prompt = await BuildPromptAsync(promptTokens, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(prompt)) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult( + Succeeded: false, + ExitCode: 1, + OutputFormat: context.OutputFormat, + Message: "No prompt text was provided. Pass text arguments or pipe input on stdin.", + DataJson: null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + var isInteractive = !forceNonInteractive + && !cliInvocationEnvironment.IsInputRedirected + && !cliInvocationEnvironment.IsOutputRedirected + && context.OutputFormat == OutputFormat.Text; + + try + { + var result = await runtimeCommandService + .ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(isInteractive: isInteractive), cancellationToken) + .ConfigureAwait(false); + await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + catch (ProviderExecutionException exception) + { + await outputRendererDispatcher.RenderCommandResultAsync( + CreateProviderFailureResult(exception, context.OutputFormat), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + } + + private async Task BuildPromptAsync(IReadOnlyList promptTokens, CancellationToken cancellationToken) + { + var promptText = string.Join(' ', promptTokens).Trim(); + var stdinText = cliInvocationEnvironment.IsInputRedirected + ? (await cliInvocationEnvironment.ReadStandardInputToEndAsync(cancellationToken).ConfigureAwait(false)).Trim() + : string.Empty; + + if (string.IsNullOrWhiteSpace(stdinText)) + { + return promptText; + } + + if (string.IsNullOrWhiteSpace(promptText)) + { + return stdinText; + } + + return $"Piped input:{Environment.NewLine}{stdinText}{Environment.NewLine}{Environment.NewLine}User request:{Environment.NewLine}{promptText}"; + } + + private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) + => new( + Succeeded: false, + ExitCode: 1, + OutputFormat: outputFormat, + Message: $"Provider failure ({exception.Kind}): {exception.Message}", + DataJson: null); +} diff --git a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs index 17fea2b..a7a9b29 100644 --- a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs +++ b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs @@ -20,7 +20,7 @@ public sealed class ReplHost( IReplTerminal terminal) : IReplHost { /// - public async Task RunAsync(CommandExecutionContext context, CancellationToken cancellationToken) + public async Task RunAsync(CommandExecutionContext context, string? initialPrompt = null, CancellationToken cancellationToken = default) { var ctrlCPressed = false; @@ -37,6 +37,15 @@ void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs args) { terminal.WriteInfo("SharpClaw Code interactive mode. Type /help for commands or /exit to quit."); + if (!string.IsNullOrWhiteSpace(initialPrompt)) + { + var initialExitCode = await ExecutePromptAsync(initialPrompt.Trim(), context, cancellationToken).ConfigureAwait(false); + if (initialExitCode != 0) + { + return initialExitCode; + } + } + while (!cancellationToken.IsCancellationRequested && !ctrlCPressed) { var input = await terminal.ReadLineAsync("sharpclaw> ", cancellationToken); @@ -94,10 +103,10 @@ await outputRendererDispatcher.RenderCommandResultAsync( var slashHandler = commandRegistry.FindSlashCommandHandler(parsed.CommandName ?? string.Empty); if (slashHandler is not null) { - var exitCode = await slashHandler.ExecuteAsync(parsed, context, cancellationToken); - if (exitCode != 0) + var slashExitCode = await slashHandler.ExecuteAsync(parsed, context, cancellationToken); + if (slashExitCode != 0) { - return exitCode; + return slashExitCode; } continue; @@ -115,7 +124,8 @@ await outputRendererDispatcher.RenderCommandResultAsync( argLine, context.ToRuntimeCommandContext( primaryModeOverride: replInteractionState.PrimaryModeOverride ?? context.PrimaryMode, - agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId), + agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId, + approvalSettingsOverride: replInteractionState.ApprovalSettingsOverride), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken); @@ -143,23 +153,10 @@ await outputRendererDispatcher.RenderCommandResultAsync( continue; } - try + var exitCode = await ExecutePromptAsync(trimmed, context, cancellationToken).ConfigureAwait(false); + if (exitCode != 0) { - var result = await runtimeCommandService.ExecutePromptAsync( - trimmed, - context.ToRuntimeCommandContext( - primaryModeOverride: replInteractionState.PrimaryModeOverride ?? context.PrimaryMode, - agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId), - cancellationToken); - - await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken); - } - catch (ProviderExecutionException exception) - { - await outputRendererDispatcher.RenderCommandResultAsync( - CreateProviderFailureResult(exception, context.OutputFormat), - context.OutputFormat, - cancellationToken); + return exitCode; } } @@ -184,6 +181,32 @@ await outputRendererDispatcher.RenderCommandResultAsync( return await customCommandDiscovery.FindAsync(workspace, name, cancellationToken).ConfigureAwait(false); } + + private async Task ExecutePromptAsync(string prompt, CommandExecutionContext context, CancellationToken cancellationToken) + { + try + { + var result = await runtimeCommandService.ExecutePromptAsync( + prompt, + context.ToRuntimeCommandContext( + primaryModeOverride: replInteractionState.PrimaryModeOverride ?? context.PrimaryMode, + agentIdOverride: replInteractionState.AgentIdOverride ?? context.AgentId, + approvalSettingsOverride: replInteractionState.ApprovalSettingsOverride), + cancellationToken).ConfigureAwait(false); + + await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + catch (ProviderExecutionException exception) + { + await outputRendererDispatcher.RenderCommandResultAsync( + CreateProviderFailureResult(exception, context.OutputFormat), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + } + private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) => new( Succeeded: false, diff --git a/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs b/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs index c466da4..0cde805 100644 --- a/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs +++ b/src/SharpClaw.Code.Commands/Repl/ReplInteractionState.cs @@ -1,4 +1,5 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Commands; @@ -16,4 +17,9 @@ public sealed class ReplInteractionState /// When set, wins over for REPL turns. /// public string? AgentIdOverride { get; set; } + + /// + /// When set, wins over for REPL turns. + /// + public ApprovalSettings? ApprovalSettingsOverride { get; set; } } diff --git a/src/SharpClaw.Code.Git/Abstractions/IGitWorkspaceService.cs b/src/SharpClaw.Code.Git/Abstractions/IGitWorkspaceService.cs index 299f549..074a9ca 100644 --- a/src/SharpClaw.Code.Git/Abstractions/IGitWorkspaceService.cs +++ b/src/SharpClaw.Code.Git/Abstractions/IGitWorkspaceService.cs @@ -14,4 +14,25 @@ public interface IGitWorkspaceService /// The cancellation token. /// The git workspace snapshot. Task GetSnapshotAsync(string workingDirectory, CancellationToken cancellationToken); + + /// + /// Lists the repository worktrees associated with the supplied directory. + /// + Task ListWorktreesAsync(string workingDirectory, CancellationToken cancellationToken); + + /// + /// Creates a new repository worktree and optionally creates the backing branch. + /// + Task CreateWorktreeAsync( + string workingDirectory, + string path, + string branchName, + string? startPoint, + bool useExistingBranch, + CancellationToken cancellationToken); + + /// + /// Prunes stale worktree administrative state from the repository. + /// + Task PruneWorktreesAsync(string workingDirectory, CancellationToken cancellationToken); } diff --git a/src/SharpClaw.Code.Git/Models/GitWorkspaceSnapshot.cs b/src/SharpClaw.Code.Git/Models/GitWorkspaceSnapshot.cs index c423911..7d6ba61 100644 --- a/src/SharpClaw.Code.Git/Models/GitWorkspaceSnapshot.cs +++ b/src/SharpClaw.Code.Git/Models/GitWorkspaceSnapshot.cs @@ -11,6 +11,9 @@ namespace SharpClaw.Code.Git.Models; /// The parsed git status entries. /// A concise status summary. /// A concise diff summary. +/// Whether the current repository root is a linked worktree instead of the main worktree. +/// The main worktree path for the repository, when known. +/// The number of worktrees associated with the repository. public sealed record GitWorkspaceSnapshot( bool IsRepository, string? RepositoryRoot, @@ -19,7 +22,10 @@ public sealed record GitWorkspaceSnapshot( GitBranchFreshness BranchFreshness, IReadOnlyList StatusEntries, string? StatusSummary, - string? DiffSummary) + string? DiffSummary, + bool IsLinkedWorktree, + string? MainWorktreePath, + int WorktreeCount) { /// /// Renders the git snapshot as a prompt-ready section. @@ -49,6 +55,16 @@ public string RenderForPrompt() lines.Add($"- Status: {StatusSummary}"); } + if (WorktreeCount > 0) + { + lines.Add($"- Worktrees: {WorktreeCount}"); + } + + if (IsLinkedWorktree) + { + lines.Add($"- Linked worktree: yes (main: {MainWorktreePath ?? "(unknown)"})"); + } + if (!string.IsNullOrWhiteSpace(DiffSummary)) { lines.Add($"- Diff: {DiffSummary}"); diff --git a/src/SharpClaw.Code.Git/Models/GitWorktreeModels.cs b/src/SharpClaw.Code.Git/Models/GitWorktreeModels.cs new file mode 100644 index 0000000..7bb0622 --- /dev/null +++ b/src/SharpClaw.Code.Git/Models/GitWorktreeModels.cs @@ -0,0 +1,42 @@ +namespace SharpClaw.Code.Git.Models; + +/// +/// Describes a single git worktree entry. +/// +public sealed record GitWorktreeEntry( + string Path, + string? Branch, + string? HeadCommitSha, + bool IsCurrent, + bool IsLocked, + bool IsPrunable, + bool IsDetached, + bool IsBare, + string? LockReason, + string? PrunableReason); + +/// +/// Lists worktrees associated with a repository. +/// +public sealed record GitWorktreeList( + string RepositoryRoot, + string MainWorktreePath, + IReadOnlyList Worktrees); + +/// +/// Describes the result of creating a git worktree. +/// +public sealed record GitWorktreeCreateResult( + string RepositoryRoot, + GitWorktreeEntry Worktree, + bool CreatedBranch, + string? StartPoint); + +/// +/// Describes the result of pruning stale git worktree metadata. +/// +public sealed record GitWorktreePruneResult( + string RepositoryRoot, + int PrunedCount, + string? Output, + IReadOnlyList Worktrees); diff --git a/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs b/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs index 127a822..0cda572 100644 --- a/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs +++ b/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs @@ -26,7 +26,10 @@ public async Task GetSnapshotAsync(string workingDirectory BranchFreshness: new GitBranchFreshness(false, 0, 0), StatusEntries: [], StatusSummary: null, - DiffSummary: null); + DiffSummary: null, + IsLinkedWorktree: false, + MainWorktreePath: null, + WorktreeCount: 0); } var repositoryRoot = NormalizeLine(repositoryRootResult.StandardOutput); @@ -35,14 +38,16 @@ public async Task GetSnapshotAsync(string workingDirectory var statusTask = RunGitAsync(workingDirectory, ["status", "--porcelain=v1", "--branch"], cancellationToken); var diffTask = RunGitAsync(workingDirectory, ["diff", "--no-ext-diff", "--stat"], cancellationToken); var freshnessTask = RunGitAsync(workingDirectory, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], cancellationToken); + var worktreeListTask = ListWorktreesCoreAsync(workingDirectory, repositoryRoot ?? workingDirectory, cancellationToken); - await Task.WhenAll(currentBranchTask, headTask, statusTask, diffTask, freshnessTask).ConfigureAwait(false); + await Task.WhenAll(currentBranchTask, headTask, statusTask, diffTask, freshnessTask, worktreeListTask).ConfigureAwait(false); var currentBranchResult = await currentBranchTask.ConfigureAwait(false); var headResult = await headTask.ConfigureAwait(false); var statusResult = await statusTask.ConfigureAwait(false); var diffResult = await diffTask.ConfigureAwait(false); var freshnessResult = await freshnessTask.ConfigureAwait(false); + var worktreeList = await worktreeListTask.ConfigureAwait(false); var statusEntries = ParseStatusEntries(statusResult.StandardOutput); return new GitWorkspaceSnapshot( @@ -53,12 +58,133 @@ public async Task GetSnapshotAsync(string workingDirectory BranchFreshness: ParseFreshness(freshnessResult), StatusEntries: statusEntries, StatusSummary: CreateStatusSummary(statusEntries), - DiffSummary: NormalizeMultiline(diffResult.StandardOutput)); + DiffSummary: NormalizeMultiline(diffResult.StandardOutput), + IsLinkedWorktree: repositoryRoot is not null && !PathsEqual(repositoryRoot, worktreeList.MainWorktreePath), + MainWorktreePath: worktreeList.MainWorktreePath, + WorktreeCount: worktreeList.Worktrees.Count); + } + + /// + public async Task ListWorktreesAsync(string workingDirectory, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + + var repositoryRootResult = await RunGitAsync(workingDirectory, ["rev-parse", "--show-toplevel"], cancellationToken).ConfigureAwait(false); + if (repositoryRootResult.ExitCode != 0) + { + throw new InvalidOperationException("The supplied directory is not a git repository."); + } + + var repositoryRoot = NormalizeLine(repositoryRootResult.StandardOutput) ?? workingDirectory; + return await ListWorktreesCoreAsync(workingDirectory, repositoryRoot, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CreateWorktreeAsync( + string workingDirectory, + string path, + string branchName, + string? startPoint, + bool useExistingBranch, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(branchName); + + var worktreeList = await ListWorktreesAsync(workingDirectory, cancellationToken).ConfigureAwait(false); + var resolvedPath = Path.IsPathRooted(path) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(workingDirectory, path)); + + var arguments = BuildCreateArguments(resolvedPath, branchName.Trim(), startPoint, useExistingBranch); + var result = await RunGitAsync(workingDirectory, arguments, cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new InvalidOperationException(BuildGitFailureMessage("Failed to create git worktree.", result)); + } + + var refreshed = await ListWorktreesCoreAsync(workingDirectory, worktreeList.RepositoryRoot, cancellationToken).ConfigureAwait(false); + var created = refreshed.Worktrees.FirstOrDefault(entry => PathsEqual(entry.Path, resolvedPath)) + ?? new GitWorktreeEntry( + resolvedPath, + branchName.Trim(), + null, + false, + false, + false, + false, + false, + null, + null); + + return new GitWorktreeCreateResult( + refreshed.RepositoryRoot, + created, + CreatedBranch: !useExistingBranch, + StartPoint: string.IsNullOrWhiteSpace(startPoint) ? null : startPoint.Trim()); + } + + /// + public async Task PruneWorktreesAsync(string workingDirectory, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + + var before = await ListWorktreesAsync(workingDirectory, cancellationToken).ConfigureAwait(false); + var pruneResult = await RunGitAsync(workingDirectory, ["worktree", "prune"], cancellationToken).ConfigureAwait(false); + if (pruneResult.ExitCode != 0) + { + throw new InvalidOperationException(BuildGitFailureMessage("Failed to prune git worktrees.", pruneResult)); + } + + var after = await ListWorktreesCoreAsync(workingDirectory, before.RepositoryRoot, cancellationToken).ConfigureAwait(false); + return new GitWorktreePruneResult( + before.RepositoryRoot, + Math.Max(before.Worktrees.Count - after.Worktrees.Count, 0), + NormalizeMultiline(string.Join(Environment.NewLine, [pruneResult.StandardOutput, pruneResult.StandardError])), + after.Worktrees); } private Task RunGitAsync(string workingDirectory, IReadOnlyList arguments, CancellationToken cancellationToken) => processRunner.RunAsync(new ProcessRunRequest("git", arguments.ToArray(), workingDirectory, null), cancellationToken); + private async Task ListWorktreesCoreAsync(string workingDirectory, string repositoryRoot, CancellationToken cancellationToken) + { + var commonDirTask = RunGitAsync(workingDirectory, ["rev-parse", "--path-format=absolute", "--git-common-dir"], cancellationToken); + var listTask = RunGitAsync(workingDirectory, ["worktree", "list", "--porcelain"], cancellationToken); + + await Task.WhenAll(commonDirTask, listTask).ConfigureAwait(false); + + var commonDirResult = await commonDirTask.ConfigureAwait(false); + var listResult = await listTask.ConfigureAwait(false); + if (listResult.ExitCode != 0) + { + throw new InvalidOperationException(BuildGitFailureMessage("Failed to list git worktrees.", listResult)); + } + + var mainWorktreePath = ResolveMainWorktreePath(commonDirResult, repositoryRoot); + var worktrees = ParseWorktreeEntries(listResult.StandardOutput, repositoryRoot, mainWorktreePath); + if (worktrees.Count == 0) + { + worktrees = + [ + new GitWorktreeEntry( + repositoryRoot, + null, + null, + true, + false, + false, + false, + false, + null, + null) + ]; + } + + return new GitWorktreeList(repositoryRoot, mainWorktreePath, worktrees); + } + private static IReadOnlyList ParseStatusEntries(string output) { var entries = new List(); @@ -112,4 +238,173 @@ private static string CreateStatusSummary(IReadOnlyList statusEn => statusEntries.Count == 0 ? "Clean working tree." : $"{statusEntries.Count} changed item(s): {string.Join(", ", statusEntries.Select(entry => entry.Path))}"; + + private static string[] BuildCreateArguments(string path, string branchName, string? startPoint, bool useExistingBranch) + { + if (useExistingBranch) + { + return ["worktree", "add", path, branchName]; + } + + return string.IsNullOrWhiteSpace(startPoint) + ? ["worktree", "add", "-b", branchName, path] + : ["worktree", "add", "-b", branchName, path, startPoint.Trim()]; + } + + private static List ParseWorktreeEntries(string output, string repositoryRoot, string mainWorktreePath) + { + var entries = new List(); + string? currentPath = null; + string? currentHead = null; + string? currentBranch = null; + string? currentLockReason = null; + string? currentPrunableReason = null; + var currentDetached = false; + var currentBare = false; + var currentLocked = false; + var currentPrunable = false; + + void Flush() + { + if (string.IsNullOrWhiteSpace(currentPath)) + { + return; + } + + var normalizedPath = currentPath.Trim(); + entries.Add(new GitWorktreeEntry( + normalizedPath, + NormalizeBranch(currentBranch), + string.IsNullOrWhiteSpace(currentHead) ? null : currentHead.Trim(), + IsCurrent: PathsEqual(normalizedPath, repositoryRoot), + IsLocked: currentLocked, + IsPrunable: currentPrunable, + IsDetached: currentDetached, + IsBare: currentBare, + LockReason: currentLockReason, + PrunableReason: currentPrunableReason)); + + currentPath = null; + currentHead = null; + currentBranch = null; + currentLockReason = null; + currentPrunableReason = null; + currentDetached = false; + currentBare = false; + currentLocked = false; + currentPrunable = false; + } + + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) + { + Flush(); + continue; + } + + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + Flush(); + currentPath = line["worktree ".Length..]; + continue; + } + + if (line.StartsWith("HEAD ", StringComparison.Ordinal)) + { + currentHead = line["HEAD ".Length..]; + continue; + } + + if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + currentBranch = line["branch ".Length..]; + continue; + } + + if (string.Equals(line, "detached", StringComparison.Ordinal)) + { + currentDetached = true; + continue; + } + + if (string.Equals(line, "bare", StringComparison.Ordinal)) + { + currentBare = true; + continue; + } + + if (line.StartsWith("locked", StringComparison.Ordinal)) + { + currentLocked = true; + currentLockReason = line.Length > "locked ".Length ? line["locked ".Length..].Trim() : null; + continue; + } + + if (line.StartsWith("prunable", StringComparison.Ordinal)) + { + currentPrunable = true; + currentPrunableReason = line.Length > "prunable ".Length ? line["prunable ".Length..].Trim() : null; + } + } + + Flush(); + if (entries.Count == 0) + { + entries.Add(new GitWorktreeEntry( + repositoryRoot, + null, + null, + IsCurrent: true, + IsLocked: false, + IsPrunable: false, + IsDetached: false, + IsBare: false, + LockReason: null, + PrunableReason: null)); + } + + return entries; + } + + private static string ResolveMainWorktreePath(ProcessRunResult commonDirResult, string repositoryRoot) + { + var commonDir = NormalizeLine(commonDirResult.StandardOutput); + if (string.IsNullOrWhiteSpace(commonDir)) + { + return repositoryRoot; + } + + if (commonDir.EndsWith($"{Path.DirectorySeparatorChar}.git", OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + return Path.GetDirectoryName(commonDir) ?? repositoryRoot; + } + + return repositoryRoot; + } + + private static string? NormalizeBranch(string? branch) + { + if (string.IsNullOrWhiteSpace(branch)) + { + return null; + } + + const string prefix = "refs/heads/"; + return branch.Trim().StartsWith(prefix, StringComparison.Ordinal) + ? branch.Trim()[prefix.Length..] + : branch.Trim(); + } + + private static bool PathsEqual(string left, string right) + => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string BuildGitFailureMessage(string prefix, ProcessRunResult result) + => string.IsNullOrWhiteSpace(result.StandardError) + ? $"{prefix} {NormalizeMultiline(result.StandardOutput) ?? "Unknown git failure."}" + : $"{prefix} {NormalizeMultiline(result.StandardError) ?? "Unknown git failure."}"; } diff --git a/src/SharpClaw.Code.Permissions/Abstractions/IAutoApprovalBudgetTracker.cs b/src/SharpClaw.Code.Permissions/Abstractions/IAutoApprovalBudgetTracker.cs new file mode 100644 index 0000000..42ec2ac --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Abstractions/IAutoApprovalBudgetTracker.cs @@ -0,0 +1,15 @@ +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Permissions.Abstractions; + +/// +/// Tracks session-scoped auto-approval budget consumption for bounded autonomy. +/// +public interface IAutoApprovalBudgetTracker +{ + /// + /// Attempts to consume a single auto-approval slot from the configured session budget. + /// + bool TryConsume(PermissionEvaluationContext context, ApprovalScope scope, int budget, out int remainingBudget); +} diff --git a/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs b/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs index f529dce..48cef36 100644 --- a/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs +++ b/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs @@ -1,4 +1,5 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Permissions.Models; @@ -20,6 +21,7 @@ namespace SharpClaw.Code.Permissions.Models; /// The manifest trust tier for the originating plugin tool, if any. /// Build vs plan workflow; plan mode blocks mutating tools. /// The active tenant identifier, when one is bound to the host context. +/// Optional bounded auto-approval settings for the current session. public sealed record PermissionEvaluationContext( string SessionId, string WorkspaceRoot, @@ -35,4 +37,5 @@ public sealed record PermissionEvaluationContext( string? ToolOriginatingPluginId = null, PluginTrustLevel? ToolOriginatingPluginTrust = null, PrimaryMode PrimaryMode = PrimaryMode.Build, - string? TenantId = null); + string? TenantId = null, + ApprovalSettings? ApprovalSettings = null); diff --git a/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs index 4d8fc58..2699cb6 100644 --- a/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class PermissionsServiceCollectionExtensions public static IServiceCollection AddSharpClawPermissions(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Permissions/Services/AutoApprovalBudgetTracker.cs b/src/SharpClaw.Code.Permissions/Services/AutoApprovalBudgetTracker.cs new file mode 100644 index 0000000..4418ddd --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Services/AutoApprovalBudgetTracker.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Permissions.Services; + +/// +/// Maintains process-local auto-approval budget counters keyed by tenant-aware session id. +/// +public sealed class AutoApprovalBudgetTracker : IAutoApprovalBudgetTracker +{ + private readonly ConcurrentDictionary usage = new(StringComparer.Ordinal); + private readonly Lock gate = new(); + + /// + public bool TryConsume(PermissionEvaluationContext context, ApprovalScope scope, int budget, out int remainingBudget) + { + ArgumentNullException.ThrowIfNull(context); + + remainingBudget = 0; + if (budget <= 0) + { + return false; + } + + var key = string.IsNullOrWhiteSpace(context.TenantId) + ? context.SessionId + : $"{context.TenantId}::{context.SessionId}"; + + lock (gate) + { + usage.TryGetValue(key, out var consumed); + if (consumed >= budget) + { + return false; + } + + consumed++; + usage[key] = consumed; + remainingBudget = budget - consumed; + return true; + } + } +} diff --git a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs index 5c074eb..9b8d86d 100644 --- a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs +++ b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs @@ -12,7 +12,8 @@ namespace SharpClaw.Code.Permissions.Services; public sealed class PermissionPolicyEngine( IEnumerable rules, IApprovalService approvalService, - ISessionApprovalMemory sessionApprovalMemory) : IPermissionPolicyEngine + ISessionApprovalMemory sessionApprovalMemory, + IAutoApprovalBudgetTracker autoApprovalBudgetTracker) : IPermissionPolicyEngine { private readonly IPermissionRule[] orderedRules = rules.ToArray(); @@ -128,6 +129,11 @@ private async Task ResolveApprovalAsync( $"Reused remembered approval for '{request.ToolName}'."); } + if (TryResolveAutoApproval(request, context, out var autoApprovedDecision)) + { + return autoApprovedDecision; + } + var approvalRequest = new ApprovalRequest( Scope: request.ApprovalScope, ToolName: request.ToolName, @@ -148,6 +154,44 @@ private async Task ResolveApprovalAsync( CombineReasons(ruleResult.Reason, approvalDecision.Reason)); } + private bool TryResolveAutoApproval( + ToolExecutionRequest request, + PermissionEvaluationContext context, + out PermissionDecision decision) + { + decision = default!; + + var settings = context.ApprovalSettings; + if (settings is null + || settings.AutoApproveScopes.Count == 0 + || !settings.AutoApproveScopes.Contains(request.ApprovalScope)) + { + return false; + } + + if (settings.AutoApproveBudget is not { } budget) + { + decision = CreateDecision( + request.ApprovalScope, + context.PermissionMode, + true, + $"Auto-approved '{request.ToolName}' for scope '{request.ApprovalScope}' via session settings."); + return true; + } + + if (autoApprovalBudgetTracker.TryConsume(context, request.ApprovalScope, budget, out var remainingBudget)) + { + decision = CreateDecision( + request.ApprovalScope, + context.PermissionMode, + true, + $"Auto-approved '{request.ToolName}' for scope '{request.ApprovalScope}' via session settings. Remaining budget: {remainingBudget}."); + return true; + } + + return false; + } + private static PermissionDecision CreateDecision(ApprovalScope scope, PermissionMode mode, bool isAllowed, string? reason) => new(scope, mode, isAllowed, reason, DateTimeOffset.UtcNow); diff --git a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs index c250875..9196ce4 100644 --- a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs +++ b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs @@ -17,6 +17,7 @@ namespace SharpClaw.Code.Protocol.Commands; /// When non-null, supplies the bounded contract for sub-agent-worker runs. /// Whether the caller can participate in approval prompts. /// Optional embedded host and tenant context. +/// Optional per-session auto-approval settings. public sealed record RunPromptRequest( string Prompt, string? SessionId, @@ -28,4 +29,5 @@ public sealed record RunPromptRequest( string? AgentId = null, DelegatedTaskContract? DelegatedTask = null, bool IsInteractive = true, - RuntimeHostContext? HostContext = null); + RuntimeHostContext? HostContext = null, + ApprovalSettings? ApprovalSettings = null); diff --git a/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs b/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs index 53689b7..1c143a6 100644 --- a/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs +++ b/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs @@ -14,6 +14,7 @@ namespace SharpClaw.Code.Protocol.Commands; /// The checkpoint captured for recovery, if any. /// The runtime events emitted during execution. /// Generated spec artifact metadata, when the turn ran in spec mode. +/// Structured deep-planning output, when the turn ran in plan mode. public sealed record TurnExecutionResult( ConversationSession Session, ConversationTurn Turn, @@ -22,4 +23,5 @@ public sealed record TurnExecutionResult( UsageSnapshot? Usage, RuntimeCheckpoint? Checkpoint, RuntimeEvent[] Events, - SpecArtifactSet? SpecArtifacts = null); + SpecArtifactSet? SpecArtifacts = null, + PlanExecutionResult? PlanResult = null); diff --git a/src/SharpClaw.Code.Protocol/Models/ApprovalSettings.cs b/src/SharpClaw.Code.Protocol/Models/ApprovalSettings.cs new file mode 100644 index 0000000..328a611 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/ApprovalSettings.cs @@ -0,0 +1,26 @@ +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Configures session-scoped auto-approval behavior for elevated operations. +/// +/// Approval scopes that may be auto-approved without an interactive prompt. +/// +/// Optional cap on the number of auto-approved elevated operations for the current session. +/// When null, matching scopes may be auto-approved without a numeric limit. +/// +public sealed record ApprovalSettings( + IReadOnlyList AutoApproveScopes, + int? AutoApproveBudget) +{ + /// + /// Gets an empty approval-settings instance that disables auto-approval. + /// + public static ApprovalSettings Empty { get; } = new([], null); + + /// + /// Gets a value indicating whether the configuration enables any auto-approval behavior. + /// + public bool HasAutoApproval => AutoApproveScopes.Count > 0; +} diff --git a/src/SharpClaw.Code.Protocol/Models/PlanModels.cs b/src/SharpClaw.Code.Protocol/Models/PlanModels.cs new file mode 100644 index 0000000..ed982ee --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/PlanModels.cs @@ -0,0 +1,74 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Structured plan task emitted by plan-mode generation. +/// +/// Stable plan task identifier. +/// Short actionable task title. +/// Desired todo status for the task. +/// Optional execution details or rationale. +/// Optional completion criteria. +public sealed record PlanTaskItem( + string Id, + string Title, + TodoStatus Status, + string? Details, + string? DoneCriteria); + +/// +/// JSON contract expected from deep plan generation. +/// +/// High-level plan summary. +/// Important assumptions the plan depends on. +/// Key delivery or implementation risks. +/// Highest-leverage next action to execute. +/// Actionable implementation tasks. +public sealed record PlanGenerationPayload( + string Summary, + List Assumptions, + List Risks, + string NextAction, + List Tasks); + +/// +/// Durable todo seed used to synchronize planning-owned session todos. +/// +/// Stable external task identifier. +/// Todo title to surface in the session. +/// Todo status to apply. +public sealed record ManagedTodoSeed( + string ExternalId, + string Title, + TodoStatus Status); + +/// +/// Summarizes the effects of synchronizing managed session todos. +/// +/// Stable owner used for the synchronized todo set. +/// Number of new todos created. +/// Number of existing todos updated. +/// Number of stale managed todos removed. +/// Current managed todos after synchronization. +public sealed record ManagedTodoSyncResult( + string OwnerAgentId, + int AddedCount, + int UpdatedCount, + int RemovedCount, + IReadOnlyList ActiveTodos); + +/// +/// Structured plan result returned from plan-mode execution. +/// +/// High-level plan summary. +/// Important assumptions the plan depends on. +/// Key delivery or implementation risks. +/// Highest-leverage next action to execute. +/// Structured plan tasks. +/// Result of synchronizing planning-owned session todos. +public sealed record PlanExecutionResult( + string Summary, + IReadOnlyList Assumptions, + IReadOnlyList Risks, + string NextAction, + IReadOnlyList Tasks, + ManagedTodoSyncResult TodoSync); diff --git a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs index 87f8668..1683075 100644 --- a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs +++ b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs @@ -61,4 +61,28 @@ public static class SharpClawWorkflowMetadataKeys /// JSON array of session-scoped records. public const string SessionTodosJson = "sharpclaw.sessionTodosJson"; + + /// JSON array of approval scopes that may be auto-approved for the session. + public const string ApprovalAutoApproveScopesJson = "sharpclaw.approvalAutoApproveScopesJson"; + + /// Optional numeric auto-approval budget for the session. + public const string ApprovalAutoApproveBudget = "sharpclaw.approvalAutoApproveBudget"; + + /// Most recent deep-planning summary captured for the session. + public const string DeepPlanningSummary = "sharpclaw.deepPlanningSummary"; + + /// Most recent deep-planning next action captured for the session. + public const string DeepPlanningNextAction = "sharpclaw.deepPlanningNextAction"; + + /// Prefix for managed todo id maps keyed by owner agent id. + public const string ManagedSessionTodoMapPrefix = "sharpclaw.managedSessionTodoMap."; + + /// + /// Builds the session-metadata key used to map managed external task ids to persisted todo ids. + /// + public static string GetManagedSessionTodoMapKey(string ownerAgentId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerAgentId); + return ManagedSessionTodoMapPrefix + ownerAgentId.Trim(); + } } diff --git a/src/SharpClaw.Code.Protocol/Models/SubAgentModels.cs b/src/SharpClaw.Code.Protocol/Models/SubAgentModels.cs new file mode 100644 index 0000000..5932334 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/SubAgentModels.cs @@ -0,0 +1,49 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Single task request for the synthetic subagent delegation tool. +/// +/// The bounded investigation goal. +/// The expected output shape. +/// Optional task constraints. +public sealed record SubAgentTaskRequest( + string Goal, + string ExpectedOutput, + string[]? Constraints); + +/// +/// Batch request for the synthetic subagent delegation tool. +/// +/// The tasks to delegate. +public sealed record SubAgentBatchRequest( + SubAgentTaskRequest[] Tasks); + +/// +/// Result for a single delegated subagent task. +/// +/// The generated delegated task id. +/// The original task goal. +/// The requested output contract. +/// Whether the subagent completed successfully. +/// The subagent output when successful. +/// The failure message when unsuccessful. +/// The agent id that executed the task. +public sealed record SubAgentTaskResult( + string TaskId, + string Goal, + string ExpectedOutput, + bool Succeeded, + string? Output, + string? ErrorMessage, + string AgentId); + +/// +/// Batch result for the synthetic subagent delegation tool. +/// +/// The individual task outcomes. +/// The number of successful task completions. +/// The number of failed task completions. +public sealed record SubAgentBatchResult( + SubAgentTaskResult[] Tasks, + int CompletedCount, + int FailedCount); diff --git a/src/SharpClaw.Code.Protocol/Operational/RuntimeStatusReport.cs b/src/SharpClaw.Code.Protocol/Operational/RuntimeStatusReport.cs index 7e21d60..a23d1a6 100644 --- a/src/SharpClaw.Code.Protocol/Operational/RuntimeStatusReport.cs +++ b/src/SharpClaw.Code.Protocol/Operational/RuntimeStatusReport.cs @@ -1,4 +1,5 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Protocol.Operational; @@ -11,6 +12,7 @@ namespace SharpClaw.Code.Protocol.Operational; /// High-level host state. /// Effective model id if known. /// Active permission mode. +/// Active bounded auto-approval settings, when configured. /// Effective primary workflow mode. /// Latest session id, if any. /// Latest session lifecycle state, if any. @@ -30,6 +32,7 @@ public sealed record RuntimeStatusReport( string RuntimeState, string? SelectedModel, PermissionMode PermissionMode, + ApprovalSettings? ApprovalSettings, PrimaryMode PrimaryMode, string? LatestSessionId, SessionLifecycleState? LatestSessionState, diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index 15593d6..24dc9cd 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -19,6 +19,7 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(ApprovalAuthMode))] [JsonSerializable(typeof(ApprovalAuthStatus))] [JsonSerializable(typeof(ApprovalDecision))] +[JsonSerializable(typeof(ApprovalSettings))] [JsonSerializable(typeof(ApprovalIdentityRequest))] [JsonSerializable(typeof(ApprovalPrincipal))] [JsonSerializable(typeof(AdminCreateSessionRequest))] @@ -29,6 +30,7 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(OperationalCheckItem))] [JsonSerializable(typeof(OperationalCheckStatus))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(PermissionMode))] [JsonSerializable(typeof(OutputFormat))] [JsonSerializable(typeof(SessionLifecycleState))] @@ -95,6 +97,11 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(RecoveryOutcome))] [JsonSerializable(typeof(RunPromptRequest))] [JsonSerializable(typeof(DelegatedTaskContract))] +[JsonSerializable(typeof(SubAgentTaskRequest))] +[JsonSerializable(typeof(SubAgentBatchRequest))] +[JsonSerializable(typeof(SubAgentTaskResult))] +[JsonSerializable(typeof(SubAgentTaskResult[]))] +[JsonSerializable(typeof(SubAgentBatchResult))] [JsonSerializable(typeof(RunSlashCommandRequest))] [JsonSerializable(typeof(RuntimeCheckpoint))] [JsonSerializable(typeof(RuntimeEvent))] @@ -163,6 +170,13 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SpecTasksDocument))] [JsonSerializable(typeof(SpecGenerationPayload))] +[JsonSerializable(typeof(PlanTaskItem))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PlanGenerationPayload))] +[JsonSerializable(typeof(ManagedTodoSeed))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ManagedTodoSyncResult))] +[JsonSerializable(typeof(PlanExecutionResult))] [JsonSerializable(typeof(SpecArtifactSet))] [JsonSerializable(typeof(ChatMessage))] [JsonSerializable(typeof(ChatMessage[]))] diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IInstructionRuleService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IInstructionRuleService.cs new file mode 100644 index 0000000..164f12e --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IInstructionRuleService.cs @@ -0,0 +1,17 @@ +using SharpClaw.Code.Runtime.Context; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Loads persistent user and workspace instruction files that should be injected into prompt context. +/// +public interface IInstructionRuleService +{ + /// + /// Loads the active instruction rules for the supplied workspace. + /// + /// The normalized workspace root. + /// The cancellation token. + /// A snapshot of discovered instruction documents. + Task LoadAsync(string workspaceRoot, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IPlanWorkflowService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IPlanWorkflowService.cs new file mode 100644 index 0000000..56a206f --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Abstractions/IPlanWorkflowService.cs @@ -0,0 +1,29 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Runtime.Abstractions; + +/// +/// Provides deep-planning prompt instructions and post-processing for plan-mode turns. +/// +public interface IPlanWorkflowService +{ + /// + /// Builds additional prompt instructions for deep plan generation. + /// + string BuildPromptInstructions(); + + /// + /// Parses plan-mode model output and synchronizes planning-owned session todos. + /// + Task MaterializeAsync( + string workspacePath, + string sessionId, + string userPrompt, + string modelOutput, + CancellationToken cancellationToken); + + /// + /// Renders a concise human-readable completion message for a deep plan result. + /// + string RenderCompletionMessage(PlanExecutionResult result); +} diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs index 5df562b..d374006 100644 --- a/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs +++ b/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs @@ -155,6 +155,7 @@ Task ImportPortableSessionBundleAsync( /// Optional effective agent id. /// Whether the caller can participate in approval prompts. /// Optional embedded host and tenant context. +/// Optional bounded auto-approval settings. public sealed record RuntimeCommandContext( string WorkingDirectory, string? Model, @@ -164,4 +165,5 @@ public sealed record RuntimeCommandContext( string? SessionId = null, string? AgentId = null, bool IsInteractive = true, - RuntimeHostContext? HostContext = null); + RuntimeHostContext? HostContext = null, + ApprovalSettings? ApprovalSettings = null); diff --git a/src/SharpClaw.Code.Runtime/Abstractions/ITodoService.cs b/src/SharpClaw.Code.Runtime/Abstractions/ITodoService.cs index f99956b..8dea1d2 100644 --- a/src/SharpClaw.Code.Runtime/Abstractions/ITodoService.cs +++ b/src/SharpClaw.Code.Runtime/Abstractions/ITodoService.cs @@ -40,4 +40,15 @@ Task UpdateAsync( /// Removes an existing todo item. /// Task RemoveAsync(string workspaceRoot, TodoScope scope, string todoId, string? sessionId, CancellationToken cancellationToken); + + /// + /// Reconciles a planning or agent-owned managed todo set against the current session-scoped todos. + /// + Task SyncManagedSessionTodosAsync( + string workspaceRoot, + string sessionId, + string ownerAgentId, + IReadOnlyList desiredTodos, + CancellationToken cancellationToken, + bool assumeSessionLockHeld = false); } diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index 16352f6..ba9a05e 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -116,7 +116,9 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs b/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs new file mode 100644 index 0000000..8065bdb --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs @@ -0,0 +1,237 @@ +using System.Text; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.Context; + +/// +/// Loads compatible workspace and user instruction files for prompt assembly. +/// +public sealed class InstructionRuleService( + IFileSystem fileSystem, + IPathService pathService, + IUserProfilePaths userProfilePaths) : IInstructionRuleService +{ + private const int MaxDocumentCount = 12; + private const int MaxDocumentCharacters = 3_500; + private const int MaxTotalCharacters = 12_000; + + /// + public async Task LoadAsync(string workspaceRoot, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); + var normalizedWorkspaceRoot = pathService.GetFullPath(workspaceRoot); + var documents = new List(); + var budget = new RuleBudget(); + + await AddDirectoryRulesAsync( + pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), "rules"), + "global", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + await AddDirectoryRulesAsync( + pathService.Combine(normalizedWorkspaceRoot, ".sharpclaw", "rules"), + "workspace", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + await AddExplicitPathAsync( + pathService.Combine(normalizedWorkspaceRoot, "AGENTS.md"), + "workspace", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + await AddExplicitPathAsync( + pathService.Combine(normalizedWorkspaceRoot, ".clinerules"), + "workspace", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + await AddExplicitPathAsync( + pathService.Combine(normalizedWorkspaceRoot, ".cursorrules"), + "workspace", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + await AddExplicitPathAsync( + pathService.Combine(normalizedWorkspaceRoot, ".windsurfrules"), + "workspace", + documents, + cancellationToken, + budget).ConfigureAwait(false); + + return new InstructionRuleSnapshot(documents); + } + + private async Task AddExplicitPathAsync( + string path, + string sourceKind, + List documents, + CancellationToken cancellationToken, + RuleBudget budget) + { + if (BudgetExhausted(documents, budget)) + { + return; + } + + if (fileSystem.FileExists(path)) + { + await AddFileAsync(path, sourceKind, documents, cancellationToken, budget).ConfigureAwait(false); + return; + } + + if (fileSystem.DirectoryExists(path)) + { + await AddDirectoryRulesAsync(path, sourceKind, documents, cancellationToken, budget).ConfigureAwait(false); + } + } + + private async Task AddDirectoryRulesAsync( + string directory, + string sourceKind, + List documents, + CancellationToken cancellationToken, + RuleBudget budget) + { + if (!fileSystem.DirectoryExists(directory) || BudgetExhausted(documents, budget)) + { + return; + } + + var pending = new Stack(); + pending.Push(pathService.GetFullPath(directory)); + + while (pending.Count > 0 && !BudgetExhausted(documents, budget)) + { + cancellationToken.ThrowIfCancellationRequested(); + var current = pending.Pop(); + + foreach (var child in fileSystem.EnumerateDirectories(current).OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).Reverse()) + { + pending.Push(child); + } + + foreach (var file in EnumerateRuleFiles(current)) + { + if (BudgetExhausted(documents, budget)) + { + break; + } + + await AddFileAsync(file, sourceKind, documents, cancellationToken, budget).ConfigureAwait(false); + } + } + } + + private IEnumerable EnumerateRuleFiles(string directory) + { + var markdownFiles = fileSystem.EnumerateFiles(directory, "*.md"); + var textFiles = fileSystem.EnumerateFiles(directory, "*.txt"); + return markdownFiles + .Concat(textFiles) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase); + } + + private async Task AddFileAsync( + string fullPath, + string sourceKind, + List documents, + CancellationToken cancellationToken, + RuleBudget budget) + { + if (BudgetExhausted(documents, budget)) + { + return; + } + + var content = await fileSystem.ReadAllTextIfExistsAsync(fullPath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(content)) + { + return; + } + + var normalized = NormalizeContent(content); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + var remaining = Math.Max(0, MaxTotalCharacters - budget.TotalCharacters); + if (remaining == 0) + { + return; + } + + var maxForDocument = Math.Min(MaxDocumentCharacters, remaining); + var isTruncated = normalized.Length > maxForDocument; + var trimmed = isTruncated + ? normalized[..Math.Max(0, maxForDocument - 28)].TrimEnd() + Environment.NewLine + "[Instruction truncated]" + : normalized; + + if (string.IsNullOrWhiteSpace(trimmed)) + { + return; + } + + documents.Add(new InstructionRuleDocument( + SourceKind: sourceKind, + DisplayPath: ToDisplayPath(fullPath), + Content: trimmed, + IsTruncated: isTruncated)); + budget.TotalCharacters += trimmed.Length; + } + + private string ToDisplayPath(string fullPath) + { + var normalizedFullPath = pathService.GetFullPath(fullPath); + var home = userProfilePaths.GetUserHomeDirectory(); + if (normalizedFullPath.StartsWith(home, StringComparison.Ordinal)) + { + return "~" + normalizedFullPath[home.Length..]; + } + + return normalizedFullPath; + } + + private static string NormalizeContent(string content) + { + var builder = new StringBuilder(content.Length); + using var reader = new StringReader(content); + string? line; + var wroteAnyLine = false; + while ((line = reader.ReadLine()) is not null) + { + var trimmed = line.TrimEnd(); + if (!wroteAnyLine && string.IsNullOrWhiteSpace(trimmed)) + { + continue; + } + + if (wroteAnyLine) + { + builder.AppendLine(); + } + + builder.Append(trimmed); + wroteAnyLine = true; + } + + return builder.ToString().Trim(); + } + + private static bool BudgetExhausted(List documents, RuleBudget budget) + => documents.Count >= MaxDocumentCount || budget.TotalCharacters >= MaxTotalCharacters; + + private sealed class RuleBudget + { + public int TotalCharacters { get; set; } + } +} diff --git a/src/SharpClaw.Code.Runtime/Context/InstructionRuleSnapshot.cs b/src/SharpClaw.Code.Runtime/Context/InstructionRuleSnapshot.cs new file mode 100644 index 0000000..88aab3d --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Context/InstructionRuleSnapshot.cs @@ -0,0 +1,21 @@ +namespace SharpClaw.Code.Runtime.Context; + +/// +/// Captures a single persistent instruction document loaded for prompt context. +/// +/// The rule source family, such as workspace or global. +/// The display path shown in prompt context. +/// The normalized rule content. +/// Whether the content was trimmed to fit prompt budget. +public sealed record InstructionRuleDocument( + string SourceKind, + string DisplayPath, + string Content, + bool IsTruncated); + +/// +/// Snapshot of instruction documents discovered for a workspace. +/// +/// The ordered instruction documents. +public sealed record InstructionRuleSnapshot( + IReadOnlyList Documents); diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs index 6829a29..6cdaadd 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs @@ -18,9 +18,11 @@ public sealed class PromptContextAssembler( IProjectMemoryService projectMemoryService, IMemoryRecallService memoryRecallService, ISessionSummaryService sessionSummaryService, + IInstructionRuleService instructionRuleService, ISkillRegistry skillRegistry, IGitWorkspaceService gitWorkspaceService, IPromptReferenceResolver promptReferenceResolver, + IPlanWorkflowService planWorkflowService, ISpecWorkflowService specWorkflowService, IWorkspaceDiagnosticsService workspaceDiagnosticsService, IWorkspaceIndexService workspaceIndexService, @@ -43,16 +45,18 @@ public async Task AssembleAsync( : request.WorkingDirectory; var memoryContextTask = projectMemoryService.BuildContextAsync(workspaceRoot, cancellationToken); var sessionSummaryTask = sessionSummaryService.BuildSummaryAsync(session, cancellationToken); + var instructionRulesTask = instructionRuleService.LoadAsync(workspaceRoot, cancellationToken); var skillsTask = skillRegistry.ListAsync(workspaceRoot, cancellationToken); var gitTask = gitWorkspaceService.GetSnapshotAsync(workspaceRoot, cancellationToken); var diagnosticsTask = workspaceDiagnosticsService.BuildSnapshotAsync(workspaceRoot, cancellationToken); var indexStatusTask = workspaceIndexService.GetStatusAsync(workspaceRoot, cancellationToken); var todoTask = todoService.GetSnapshotAsync(workspaceRoot, session.Id, cancellationToken); - await Task.WhenAll(memoryContextTask, sessionSummaryTask, skillsTask, gitTask, diagnosticsTask, indexStatusTask, todoTask).ConfigureAwait(false); + await Task.WhenAll(memoryContextTask, sessionSummaryTask, instructionRulesTask, skillsTask, gitTask, diagnosticsTask, indexStatusTask, todoTask).ConfigureAwait(false); var memoryContext = await memoryContextTask.ConfigureAwait(false); var sessionSummary = await sessionSummaryTask.ConfigureAwait(false); + var instructionRules = await instructionRulesTask.ConfigureAwait(false); var skills = await skillsTask.ConfigureAwait(false); var gitSnapshot = await gitTask.ConfigureAwait(false); var diagnostics = await diagnosticsTask.ConfigureAwait(false); @@ -134,6 +138,24 @@ public async Task AssembleAsync( sections.Add($"Session summary:\n{sessionSummary}"); } + if (session.Metadata is not null + && session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.DeepPlanningSummary, out var deepPlanningSummary) + && !string.IsNullOrWhiteSpace(deepPlanningSummary)) + { + var nextAction = session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.DeepPlanningNextAction, out var storedNextAction) + ? storedNextAction + : null; + sections.Add( + string.IsNullOrWhiteSpace(nextAction) + ? $"Latest deep plan:\n{deepPlanningSummary}" + : $"Latest deep plan:\n{deepPlanningSummary}\nNext action: {nextAction}"); + } + + if (instructionRules.Documents.Count > 0) + { + sections.Add(RenderInstructionRules(instructionRules)); + } + if (skills.Count > 0) { sections.Add( @@ -182,7 +204,11 @@ public async Task AssembleAsync( + $"Indexed files: {indexStatus.IndexedFileCount}, chunks: {indexStatus.ChunkCount}, symbols: {indexStatus.SymbolCount}"); } - if (effectivePrimary == Protocol.Enums.PrimaryMode.Spec) + if (effectivePrimary == Protocol.Enums.PrimaryMode.Plan) + { + sections.Add(planWorkflowService.BuildPromptInstructions()); + } + else if (effectivePrimary == Protocol.Enums.PrimaryMode.Spec) { sections.Add(specWorkflowService.BuildPromptInstructions()); } @@ -212,4 +238,16 @@ public async Task AssembleAsync( Metadata: metadata, ConversationHistory: conversationHistory); } + + private static string RenderInstructionRules(InstructionRuleSnapshot snapshot) + { + var lines = new List { "Persistent rules:" }; + foreach (var document in snapshot.Documents) + { + lines.Add($"Source: {document.SourceKind} {document.DisplayPath}"); + lines.Add(document.Content); + } + + return string.Join(Environment.NewLine, lines); + } } diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs index 8814132..3b3fe17 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs @@ -8,6 +8,7 @@ using SharpClaw.Code.Protocol.Operational; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Runtime.Mutations; +using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Sessions.Abstractions; namespace SharpClaw.Code.Runtime.Diagnostics; @@ -83,6 +84,7 @@ public async Task BuildStatusReportAsync(OperationalDiagnos var diagnostics = await workspaceDiagnosticsService.BuildSnapshotAsync(workspacePath, cancellationToken).ConfigureAwait(false); var primaryMode = ResolvePrimaryModeForStatus(input, latest); + var approvalSettings = ResolveApprovalSettingsForStatus(input, latest); return new RuntimeStatusReport( SchemaVersion: "1.0", @@ -91,6 +93,7 @@ public async Task BuildStatusReportAsync(OperationalDiagnos RuntimeState: "ready", SelectedModel: string.IsNullOrWhiteSpace(input.Model) ? "default" : input.Model, PermissionMode: input.PermissionMode, + ApprovalSettings: approvalSettings, PrimaryMode: primaryMode, LatestSessionId: latest?.Id, LatestSessionState: latest?.State, @@ -168,6 +171,29 @@ private static PrimaryMode ResolvePrimaryModeForStatus(OperationalDiagnosticsInp return PrimaryMode.Build; } + private static ApprovalSettings? ResolveApprovalSettingsForStatus(OperationalDiagnosticsInput input, ConversationSession? latest) + { + if (input.ApprovalSettings is not null) + { + return ApprovalSettingsResolver.Normalize(input.ApprovalSettings); + } + + if (latest is null) + { + return null; + } + + return ApprovalSettingsResolver.ResolveEffective( + new Protocol.Commands.RunPromptRequest( + Prompt: "status", + SessionId: latest.Id, + WorkingDirectory: latest.WorkingDirectory, + PermissionMode: latest.PermissionMode, + OutputFormat: latest.OutputFormat, + Metadata: null), + latest); + } + private static OperationalCheckStatus Reduce(IReadOnlyList items) { if (items.Any(i => i.Status == OperationalCheckStatus.Error)) diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsInput.cs b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsInput.cs index 51639e1..8dfef4d 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsInput.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsInput.cs @@ -1,4 +1,5 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Runtime.Diagnostics; @@ -10,9 +11,11 @@ namespace SharpClaw.Code.Runtime.Diagnostics; /// Permission mode. /// Requested output format. /// Optional primary mode hint; session metadata wins when null. +/// Optional bounded auto-approval settings. public sealed record OperationalDiagnosticsInput( string WorkingDirectory, string? Model, PermissionMode PermissionMode, OutputFormat OutputFormat, - PrimaryMode? PrimaryMode = null); + PrimaryMode? PrimaryMode = null, + ApprovalSettings? ApprovalSettings = null); diff --git a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs index 477fd73..6c1f146 100644 --- a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs +++ b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs @@ -50,6 +50,7 @@ public sealed class ConversationRuntime( CheckpointMutationCoordinator checkpointMutationCoordinator, ISessionCoordinator sessionCoordinator, IPortableSessionBundleService portableSessionBundleService, + IPlanWorkflowService planWorkflowService, ISpecWorkflowService specWorkflowService, ISharpClawConfigService sharpClawConfigService, IAgentCatalogService agentCatalogService, @@ -128,6 +129,7 @@ public async Task RunPromptAsync(RunPromptRequest request, } session = await EnsurePrimaryModePersistedAsync(workspacePath, session, request.PrimaryMode, cancellationToken).ConfigureAwait(false); + session = await EnsureApprovalSettingsPersistedAsync(workspacePath, session, request.ApprovalSettings, cancellationToken).ConfigureAwait(false); var sessionMutex = GetSessionMutex(workspacePath, session.Id); await sessionMutex.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -246,15 +248,31 @@ await AppendEventAsync( try { var effectivePrimary = PrimaryModeResolver.ResolveEffective(request, session); + var effectiveApprovalSettings = ApprovalSettingsResolver.ResolveEffective(request, session); var runnerRequest = request with { WorkingDirectory = workspacePath, - Metadata = MergeMetadata(request.Metadata, request.PermissionMode, request.OutputFormat, effectivePrimary) + Metadata = MergeMetadata(request.Metadata, request.PermissionMode, request.OutputFormat, effectivePrimary, effectiveApprovalSettings), + ApprovalSettings = effectiveApprovalSettings }; var turnRunResult = await turnRunner.RunAsync(session, turn, runnerRequest, cancellationToken).ConfigureAwait(false); SpecArtifactSet? specArtifacts = null; - if (effectivePrimary == PrimaryMode.Spec) + PlanExecutionResult? planResult = null; + if (effectivePrimary == PrimaryMode.Plan) + { + planResult = await planWorkflowService + .MaterializeAsync(workspacePath, session.Id, request.Prompt, turnRunResult.Output, cancellationToken) + .ConfigureAwait(false); + session = await sessionStore.GetByIdAsync(workspacePath, session.Id, cancellationToken).ConfigureAwait(false) ?? session; + + turnRunResult = turnRunResult with + { + Output = planWorkflowService.RenderCompletionMessage(planResult), + Summary = planResult.Summary + }; + } + else if (effectivePrimary == PrimaryMode.Spec) { specArtifacts = await specWorkflowService .MaterializeAsync(workspacePath, request.Prompt, turnRunResult.Output, cancellationToken) @@ -396,7 +414,8 @@ await AppendEventAsync( Usage: turnRunResult.Usage, Checkpoint: checkpoint, Events: runtimeEvents.ToArray(), - SpecArtifacts: finalSpecArtifacts); + SpecArtifacts: finalSpecArtifacts, + PlanResult: planResult); } catch (OperationCanceledException exception) { @@ -472,7 +491,8 @@ public Task ExecutePromptAsync(string prompt, RuntimeComman PrimaryMode: context.PrimaryMode, AgentId: context.AgentId, IsInteractive: context.IsInteractive, - HostContext: context.HostContext), + HostContext: context.HostContext, + ApprovalSettings: context.ApprovalSettings), cancellationToken); /// @@ -519,7 +539,8 @@ public async Task ExecuteCustomCommandAsync( PrimaryMode: primary, AgentId: definition.AgentId, IsInteractive: context.IsInteractive, - HostContext: context.HostContext), + HostContext: context.HostContext, + ApprovalSettings: context.ApprovalSettings), cancellationToken).ConfigureAwait(false); } @@ -532,7 +553,8 @@ public async Task GetStatusAsync(RuntimeCommandContext context, C context.Model, context.PermissionMode, context.OutputFormat, - context.PrimaryMode); + context.PrimaryMode, + context.ApprovalSettings); var report = await operationalDiagnostics .BuildStatusReportAsync(input, cancellationToken) .ConfigureAwait(false); @@ -556,7 +578,8 @@ public async Task RunDoctorAsync(RuntimeCommandContext context, C context.Model, context.PermissionMode, context.OutputFormat, - context.PrimaryMode); + context.PrimaryMode, + context.ApprovalSettings); var report = await operationalDiagnostics.RunDoctorAsync(input, cancellationToken).ConfigureAwait(false); var payload = JsonSerializer.Serialize(report, ProtocolJsonContext.Default.DoctorReport); var exitCode = report.OverallStatus == OperationalCheckStatus.Error ? 1 : 0; @@ -579,7 +602,8 @@ public async Task InspectSessionAsync(string? sessionId, RuntimeC context.Model, context.PermissionMode, context.OutputFormat, - context.PrimaryMode); + context.PrimaryMode, + context.ApprovalSettings); var inspection = await operationalDiagnostics .InspectSessionAsync(sessionId, input, cancellationToken) .ConfigureAwait(false); @@ -1034,6 +1058,66 @@ private async Task EnsurePrimaryModePersistedAsync( return updated; } + private async Task EnsureApprovalSettingsPersistedAsync( + string workspacePath, + ConversationSession session, + ApprovalSettings? approvalSettings, + CancellationToken cancellationToken) + { + if (approvalSettings is null) + { + return session; + } + + var normalized = ApprovalSettingsResolver.Normalize(approvalSettings); + var metadata = CloneMetadata(session.Metadata); + var changed = false; + + if (normalized.AutoApproveScopes.Count == 0) + { + changed |= metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson); + } + else + { + var serializedScopes = JsonSerializer.Serialize(normalized.AutoApproveScopes.ToList(), ProtocolJsonContext.Default.ListApprovalScope); + if (!metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson, out var currentScopes) + || !string.Equals(currentScopes, serializedScopes, StringComparison.Ordinal)) + { + metadata[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson] = serializedScopes; + changed = true; + } + } + + if (normalized.AutoApproveBudget is null) + { + changed |= metadata.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget); + } + else + { + var budgetText = normalized.AutoApproveBudget.Value.ToString(CultureInfo.InvariantCulture); + if (!metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget, out var currentBudget) + || !string.Equals(currentBudget, budgetText, StringComparison.Ordinal)) + { + metadata[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget] = budgetText; + changed = true; + } + } + + if (!changed) + { + return session; + } + + var updated = session with + { + Metadata = metadata, + UpdatedAtUtc = systemClock.UtcNow, + }; + + await sessionStore.SaveAsync(workspacePath, updated, cancellationToken).ConfigureAwait(false); + return updated; + } + private static string FormatDoctorMessage(DoctorReport report) => string.Join( Environment.NewLine, @@ -1048,8 +1132,15 @@ private static string FormatStatusMessage(RuntimeStatusReport report) var sessionLabel = report.LatestSessionId is null ? "no session" : $"{report.LatestSessionId} ({report.LatestSessionState})"; + var approvalLabel = report.ApprovalSettings is null + ? "manual approvals" + : report.ApprovalSettings.AutoApproveScopes.Count == 0 + ? "manual approvals" + : report.ApprovalSettings.AutoApproveBudget is { } budget + ? $"auto-approve {string.Join(',', report.ApprovalSettings.AutoApproveScopes)} (budget {budget})" + : $"auto-approve {string.Join(',', report.ApprovalSettings.AutoApproveScopes)}"; var headline = - $"Ready · {report.WorkspaceRoot} · model {report.SelectedModel} · mode {report.PrimaryMode} · latest {sessionLabel} · " + $"Ready · {report.WorkspaceRoot} · model {report.SelectedModel} · mode {report.PrimaryMode} · approvals {approvalLabel} · latest {sessionLabel} · " + $"MCP {report.McpReadyCount}/{report.McpServerCount} · plugins {report.PluginEnabledCount}/{report.PluginInstalledCount} · " + $"LSP {report.LspServerCount} · diagnostics {report.DiagnosticsErrorCount} error(s), {report.DiagnosticsWarningCount} warning(s)"; var notable = report.Checks.FirstOrDefault(static c @@ -1483,12 +1574,33 @@ private static Dictionary MergeMetadata( Dictionary? metadata, PermissionMode permissionMode, OutputFormat outputFormat, - PrimaryMode primaryMode) + PrimaryMode primaryMode, + ApprovalSettings? approvalSettings) { var merged = CloneMetadata(metadata); merged["permissionMode"] = permissionMode.ToString(); merged["outputFormat"] = outputFormat.ToString(); merged[SharpClawWorkflowMetadataKeys.PrimaryMode] = primaryMode.ToString(); + if (approvalSettings is not null && approvalSettings.AutoApproveScopes.Count > 0) + { + merged[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson] = JsonSerializer.Serialize( + approvalSettings.AutoApproveScopes.ToList(), + ProtocolJsonContext.Default.ListApprovalScope); + } + else + { + merged.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson); + } + + if (approvalSettings?.AutoApproveBudget is { } budget) + { + merged[SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget] = budget.ToString(CultureInfo.InvariantCulture); + } + else + { + merged.Remove(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget); + } + return merged; } diff --git a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs index 41f4bff..36394d7 100644 --- a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs +++ b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs @@ -70,6 +70,7 @@ await EnsureOutsideWorkspaceAllowedAsync( request.PermissionMode, primaryMode, isInteractive, + request.ApprovalSettings, request.Metadata is not null && request.Metadata.TryGetValue("acp", out var acp) && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase), @@ -115,6 +116,7 @@ private async Task EnsureOutsideWorkspaceAllowedAsync( PermissionMode permissionMode, PrimaryMode primaryMode, bool isInteractive, + ApprovalSettings? approvalSettings, bool isAcp, string absolutePath, CancellationToken cancellationToken) @@ -146,7 +148,8 @@ private async Task EnsureOutsideWorkspaceAllowedAsync( TrustedPluginNames: null, TrustedMcpServerNames: null, PrimaryMode: primaryMode, - TenantId: hostContextAccessor?.Current?.TenantId); + TenantId: hostContextAccessor?.Current?.TenantId, + ApprovalSettings: approvalSettings); var decision = await permissionPolicyEngine .EvaluateAsync(toolRequest, context, cancellationToken) diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs index 8e28333..f959a96 100644 --- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs +++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs @@ -58,7 +58,8 @@ public async Task RunAsync( ToolMutationRecorder: mutationAccumulator, DelegatedTask: request.DelegatedTask, ConversationHistory: promptContext.ConversationHistory, - IsInteractive: request.IsInteractive); + IsInteractive: request.IsInteractive, + ApprovalSettings: request.ApprovalSettings); using var turnScope = new TurnActivityScope(session.Id, turn.Id, promptContext.Prompt); var sw = Stopwatch.StartNew(); diff --git a/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs b/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs new file mode 100644 index 0000000..bac0679 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Runtime.Workflow; + +internal static class ApprovalSettingsResolver +{ + public static ApprovalSettings? ResolveEffective(RunPromptRequest request, ConversationSession session) + { + if (request.ApprovalSettings is not null) + { + return Normalize(request.ApprovalSettings); + } + + if (session.Metadata is null) + { + return null; + } + + var scopes = ReadScopes(session.Metadata); + var budget = ReadBudget(session.Metadata); + return scopes is null && budget is null + ? null + : Normalize(new ApprovalSettings(scopes ?? [], budget)); + } + + public static ApprovalSettings Normalize(ApprovalSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var scopes = settings.AutoApproveScopes + .Where(scope => scope != ApprovalScope.None) + .Distinct() + .OrderBy(static scope => scope) + .ToArray(); + var budget = settings.AutoApproveBudget is > 0 ? settings.AutoApproveBudget : null; + + return scopes.Length == 0 && budget is null + ? ApprovalSettings.Empty + : new ApprovalSettings(scopes, budget); + } + + private static IReadOnlyList? ReadScopes(IReadOnlyDictionary metadata) + { + if (!metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveScopesJson, out var payload) + || string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.ListApprovalScope); + } + catch (JsonException) + { + return null; + } + } + + private static int? ReadBudget(IReadOnlyDictionary metadata) + => metadata.TryGetValue(SharpClawWorkflowMetadataKeys.ApprovalAutoApproveBudget, out var payload) + && int.TryParse(payload, out var parsed) + && parsed > 0 + ? parsed + : null; +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/PlanWorkflowService.cs b/src/SharpClaw.Code.Runtime/Workflow/PlanWorkflowService.cs new file mode 100644 index 0000000..0c1cfc7 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Workflow/PlanWorkflowService.cs @@ -0,0 +1,276 @@ +using System.Text; +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Runtime.Workflow; + +/// +/// Parses structured plan-mode output and synchronizes planning-owned session todos. +/// +public sealed class PlanWorkflowService( + ITodoService todoService, + ISessionStore sessionStore, + IFileSystem fileSystem, + IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, + ISystemClock systemClock) : IPlanWorkflowService +{ + private const string PlanningOwnerAgentId = "deep-planning"; + + /// + public string BuildPromptInstructions() + => """ + Plan mode is active. + + Think deeply about constraints, sequencing, and risk. Respond with JSON only. Do not include prose before or after the JSON. + + Required JSON shape: + { + "summary": "One-paragraph plan summary", + "assumptions": ["Key assumption"], + "risks": ["Important delivery or implementation risk"], + "nextAction": "Highest-leverage next step", + "tasks": [ + { + "id": "PLAN-001", + "title": "Actionable task title", + "status": "open", + "details": "Optional implementation detail", + "doneCriteria": "Optional completion criteria" + } + ] + } + + Keep tasks concrete and implementation-oriented. Use status values: open, inProgress, blocked, or done. + """; + + /// + public async Task MaterializeAsync( + string workspacePath, + string sessionId, + string userPrompt, + string modelOutput, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); + ArgumentException.ThrowIfNullOrWhiteSpace(modelOutput); + + var payload = DeserializePayload(modelOutput); + ValidatePayload(payload); + + var sync = await todoService + .SyncManagedSessionTodosAsync( + workspacePath, + sessionId, + PlanningOwnerAgentId, + payload.Tasks.Select(task => new ManagedTodoSeed(task.Id.Trim(), FormatTodoTitle(task), task.Status)).ToArray(), + cancellationToken, + assumeSessionLockHeld: true) + .ConfigureAwait(false); + + await PersistSessionSummaryAsync(workspacePath, sessionId, payload, cancellationToken, assumeSessionLockHeld: true).ConfigureAwait(false); + + return new PlanExecutionResult( + payload.Summary.Trim(), + payload.Assumptions.Where(static value => !string.IsNullOrWhiteSpace(value)).Select(static value => value.Trim()).ToArray(), + payload.Risks.Where(static value => !string.IsNullOrWhiteSpace(value)).Select(static value => value.Trim()).ToArray(), + payload.NextAction.Trim(), + payload.Tasks.Select(NormalizeTask).ToArray(), + sync); + } + + /// + public string RenderCompletionMessage(PlanExecutionResult result) + { + ArgumentNullException.ThrowIfNull(result); + + var builder = new StringBuilder(); + builder.AppendLine("Deep plan ready.") + .Append("Summary: ").AppendLine(result.Summary) + .Append("Next: ").AppendLine(result.NextAction) + .Append("Todo sync: ") + .Append(result.TodoSync.AddedCount) + .Append(" added, ") + .Append(result.TodoSync.UpdatedCount) + .Append(" updated, ") + .Append(result.TodoSync.RemovedCount) + .AppendLine(" removed."); + + if (result.Assumptions.Count > 0) + { + builder.AppendLine().AppendLine("Assumptions:"); + foreach (var assumption in result.Assumptions) + { + builder.Append("- ").AppendLine(assumption); + } + } + + if (result.Risks.Count > 0) + { + builder.AppendLine().AppendLine("Risks:"); + foreach (var risk in result.Risks) + { + builder.Append("- ").AppendLine(risk); + } + } + + if (result.Tasks.Count > 0) + { + builder.AppendLine().AppendLine("Tasks:"); + foreach (var task in result.Tasks) + { + builder.Append("- [") + .Append(task.Status) + .Append("] ") + .Append(task.Id) + .Append(": ") + .AppendLine(task.Title); + } + } + + return builder.ToString().TrimEnd(); + } + + private PlanGenerationPayload DeserializePayload(string modelOutput) + { + var candidates = new List { modelOutput.Trim() }; + if (TryStripCodeFence(modelOutput) is { } stripped) + { + candidates.Add(stripped); + } + + if (TryExtractJsonObject(modelOutput) is { } extracted) + { + candidates.Add(extracted); + } + + foreach (var candidate in candidates.Where(static value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal)) + { + try + { + var payload = JsonSerializer.Deserialize(candidate, ProtocolJsonContext.Default.PlanGenerationPayload); + if (payload is not null) + { + return payload; + } + } + catch (JsonException) + { + // Try the next candidate. + } + } + + throw new InvalidOperationException("Plan mode expected a valid structured JSON response containing summary, risks, nextAction, and tasks."); + } + + private static void ValidatePayload(PlanGenerationPayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + + if (string.IsNullOrWhiteSpace(payload.Summary) + || string.IsNullOrWhiteSpace(payload.NextAction) + || payload.Assumptions is null + || payload.Risks is null + || payload.Tasks is null) + { + throw new InvalidOperationException("Plan mode output was incomplete. Summary, nextAction, and task collection are required."); + } + + if (payload.Tasks.Any(static task => + string.IsNullOrWhiteSpace(task.Id) + || string.IsNullOrWhiteSpace(task.Title))) + { + throw new InvalidOperationException("Plan mode tasks must contain non-empty ids and titles."); + } + } + + private async Task PersistSessionSummaryAsync( + string workspacePath, + string sessionId, + PlanGenerationPayload payload, + CancellationToken cancellationToken, + bool assumeSessionLockHeld) + { + var normalizedWorkspacePath = pathService.GetFullPath(workspacePath); + if (!assumeSessionLockHeld) + { + await using var gate = await fileSystem + .AcquireExclusiveFileLockAsync(storagePathResolver.GetSessionTurnLockPath(normalizedWorkspacePath, sessionId), cancellationToken) + .ConfigureAwait(false); + await PersistSessionSummaryCoreAsync(normalizedWorkspacePath, sessionId, payload, cancellationToken).ConfigureAwait(false); + return; + } + + await PersistSessionSummaryCoreAsync(normalizedWorkspacePath, sessionId, payload, cancellationToken).ConfigureAwait(false); + } + + private async Task PersistSessionSummaryCoreAsync( + string normalizedWorkspacePath, + string sessionId, + PlanGenerationPayload payload, + CancellationToken cancellationToken) + { + var session = await sessionStore.GetByIdAsync(normalizedWorkspacePath, sessionId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Session '{sessionId}' was not found."); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + metadata[SharpClawWorkflowMetadataKeys.DeepPlanningSummary] = payload.Summary.Trim(); + metadata[SharpClawWorkflowMetadataKeys.DeepPlanningNextAction] = payload.NextAction.Trim(); + + var updated = session with + { + UpdatedAtUtc = systemClock.UtcNow, + Metadata = metadata, + }; + + await sessionStore.SaveAsync(normalizedWorkspacePath, updated, cancellationToken).ConfigureAwait(false); + } + + private static PlanTaskItem NormalizeTask(PlanTaskItem task) + => task with + { + Id = task.Id.Trim(), + Title = task.Title.Trim(), + Details = string.IsNullOrWhiteSpace(task.Details) ? null : task.Details.Trim(), + DoneCriteria = string.IsNullOrWhiteSpace(task.DoneCriteria) ? null : task.DoneCriteria.Trim(), + }; + + private static string FormatTodoTitle(PlanTaskItem task) + => $"{task.Id.Trim()}: {task.Title.Trim()}"; + + private static string? TryStripCodeFence(string value) + { + var trimmed = value.Trim(); + if (!trimmed.StartsWith("```", StringComparison.Ordinal) || !trimmed.EndsWith("```", StringComparison.Ordinal)) + { + return null; + } + + var firstNewLine = trimmed.IndexOf('\n'); + if (firstNewLine < 0) + { + return null; + } + + return trimmed[(firstNewLine + 1)..^3].Trim(); + } + + private static string? TryExtractJsonObject(string value) + { + var start = value.IndexOf('{'); + var end = value.LastIndexOf('}'); + if (start < 0 || end <= start) + { + return null; + } + + return value[start..(end + 1)].Trim(); + } +} diff --git a/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs b/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs index c1a4655..9109853 100644 --- a/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs +++ b/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs @@ -89,6 +89,46 @@ public async Task RemoveAsync(string workspaceRoot, TodoScope scope, strin }; } + /// + public async Task SyncManagedSessionTodosAsync( + string workspaceRoot, + string sessionId, + string ownerAgentId, + IReadOnlyList desiredTodos, + CancellationToken cancellationToken, + bool assumeSessionLockHeld = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerAgentId); + ArgumentNullException.ThrowIfNull(desiredTodos); + + var normalizedWorkspaceRoot = pathService.GetFullPath(workspaceRoot); + var normalizedOwnerAgentId = ownerAgentId.Trim(); + var mapMetadataKey = SharpClawWorkflowMetadataKeys.GetManagedSessionTodoMapKey(normalizedOwnerAgentId); + + if (assumeSessionLockHeld) + { + return await SyncManagedSessionTodosCoreAsync( + normalizedWorkspaceRoot, + sessionId, + normalizedOwnerAgentId, + mapMetadataKey, + desiredTodos, + cancellationToken).ConfigureAwait(false); + } + + await using var gate = await fileSystem + .AcquireExclusiveFileLockAsync(storagePathResolver.GetSessionTurnLockPath(normalizedWorkspaceRoot, sessionId), cancellationToken) + .ConfigureAwait(false); + return await SyncManagedSessionTodosCoreAsync( + normalizedWorkspaceRoot, + sessionId, + normalizedOwnerAgentId, + mapMetadataKey, + desiredTodos, + cancellationToken).ConfigureAwait(false); + } + private async Task ReadSessionTodosAsync(string workspaceRoot, string? sessionId, CancellationToken cancellationToken) { var resolvedSessionId = RequireSessionId(sessionId); @@ -338,6 +378,38 @@ private Task WriteWorkspaceTodosAsync(string workspaceRoot, IReadOnlyList ReadManagedTodoMap(IReadOnlyDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var payload) || string.IsNullOrWhiteSpace(payload)) + { + return new Dictionary(StringComparer.Ordinal); + } + + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.DictionaryStringString) + ?? new Dictionary(StringComparer.Ordinal); + } + + private static TodoItem? ResolveManagedTodo( + IReadOnlyDictionary existingMap, + IReadOnlyList todos, + string ownerAgentId, + string externalId, + Queue unusedManagedTodos) + { + if (existingMap.TryGetValue(externalId, out var todoId)) + { + var mapped = todos.FirstOrDefault(item => + string.Equals(item.Id, todoId, StringComparison.Ordinal) + && string.Equals(item.OwnerAgentId, ownerAgentId, StringComparison.Ordinal)); + if (mapped is not null) + { + return mapped; + } + } + + return unusedManagedTodos.Count > 0 ? unusedManagedTodos.Dequeue() : null; + } + private static List ReadSessionTodoList(ConversationSession session) => session.Metadata is not null && session.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.SessionTodosJson, out var todosJson) @@ -352,6 +424,10 @@ private static TodoItem[] Sort(IEnumerable items) .ThenBy(static item => item.Title, StringComparer.OrdinalIgnoreCase) .ToArray(); + private static bool DictionaryEquals(IReadOnlyDictionary left, IReadOnlyDictionary right) + => left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var rightValue) && string.Equals(pair.Value, rightValue, StringComparison.Ordinal)); + private static string RequireSessionId(string? sessionId) => string.IsNullOrWhiteSpace(sessionId) ? throw new InvalidOperationException("A session id is required for session-scoped todos.") @@ -359,4 +435,138 @@ private static string RequireSessionId(string? sessionId) private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private async Task SyncManagedSessionTodosCoreAsync( + string normalizedWorkspaceRoot, + string sessionId, + string normalizedOwnerAgentId, + string mapMetadataKey, + IReadOnlyList desiredTodos, + CancellationToken cancellationToken) + { + var session = await sessionStore.GetByIdAsync(normalizedWorkspaceRoot, sessionId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Session '{sessionId}' was not found."); + var todos = ReadSessionTodoList(session); + var metadata = session.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(session.Metadata, StringComparer.Ordinal); + var existingMap = ReadManagedTodoMap(metadata, mapMetadataKey); + var normalizedDesired = desiredTodos + .Where(static item => !string.IsNullOrWhiteSpace(item.ExternalId) && !string.IsNullOrWhiteSpace(item.Title)) + .GroupBy(static item => item.ExternalId.Trim(), StringComparer.Ordinal) + .Select(static group => group.Last() with { ExternalId = group.Key, Title = group.Last().Title.Trim() }) + .ToArray(); + + var nextMap = new Dictionary(StringComparer.Ordinal); + var touchedTodoIds = new HashSet(StringComparer.Ordinal); + var unusedManagedTodos = new Queue(todos.Where(item => + string.Equals(item.OwnerAgentId, normalizedOwnerAgentId, StringComparison.Ordinal) + && !existingMap.Values.Contains(item.Id, StringComparer.Ordinal))); + var mutatedTodos = new List<(string Action, TodoItem Todo)>(); + var addedCount = 0; + var updatedCount = 0; + + foreach (var seed in normalizedDesired) + { + var existing = ResolveManagedTodo(existingMap, todos, normalizedOwnerAgentId, seed.ExternalId, unusedManagedTodos); + if (existing is null) + { + var now = systemClock.UtcNow; + var created = new TodoItem( + $"todo-{Guid.NewGuid():N}", + seed.Title, + seed.Status, + TodoScope.Session, + now, + now, + normalizedOwnerAgentId, + sessionId); + todos.Add(created); + nextMap[seed.ExternalId] = created.Id; + touchedTodoIds.Add(created.Id); + mutatedTodos.Add(("added", created)); + addedCount++; + continue; + } + + var updated = existing with + { + Title = seed.Title, + Status = seed.Status, + OwnerAgentId = normalizedOwnerAgentId, + LinkedSessionId = sessionId, + UpdatedAtUtc = systemClock.UtcNow, + }; + + var index = todos.FindIndex(item => string.Equals(item.Id, existing.Id, StringComparison.Ordinal)); + if (index < 0) + { + continue; + } + + if (!Equals(existing, updated)) + { + todos[index] = updated; + mutatedTodos.Add(("updated", updated)); + updatedCount++; + } + + nextMap[seed.ExternalId] = updated.Id; + touchedTodoIds.Add(updated.Id); + } + + var removedTodos = todos + .Where(item => string.Equals(item.OwnerAgentId, normalizedOwnerAgentId, StringComparison.Ordinal) + && !touchedTodoIds.Contains(item.Id)) + .ToArray(); + foreach (var removed in removedTodos) + { + todos.RemoveAll(item => string.Equals(item.Id, removed.Id, StringComparison.Ordinal)); + mutatedTodos.Add(("removed", removed)); + } + + var removedCount = removedTodos.Length; + var metadataChanged = !DictionaryEquals(existingMap, nextMap); + if (mutatedTodos.Count > 0 || metadataChanged) + { + metadata[SharpClawWorkflowMetadataKeys.SessionTodosJson] = JsonSerializer.Serialize(Sort(todos).ToList(), ProtocolJsonContext.Default.ListTodoItem); + if (nextMap.Count == 0) + { + metadata.Remove(mapMetadataKey); + } + else + { + metadata[mapMetadataKey] = JsonSerializer.Serialize(nextMap, ProtocolJsonContext.Default.DictionaryStringString); + } + + var updatedSession = session with + { + UpdatedAtUtc = systemClock.UtcNow, + Metadata = metadata, + }; + + await sessionStore.SaveAsync(normalizedWorkspaceRoot, updatedSession, cancellationToken).ConfigureAwait(false); + foreach (var mutation in mutatedTodos) + { + await eventStore.AppendAsync( + normalizedWorkspaceRoot, + updatedSession.Id, + new TodoChangedEvent( + $"event-{Guid.NewGuid():N}", + updatedSession.Id, + null, + systemClock.UtcNow, + mutation.Action, + mutation.Todo), + cancellationToken).ConfigureAwait(false); + } + } + + return new ManagedTodoSyncResult( + normalizedOwnerAgentId, + addedCount, + updatedCount, + removedCount, + Sort(todos.Where(item => string.Equals(item.OwnerAgentId, normalizedOwnerAgentId, StringComparison.Ordinal)))); + } } diff --git a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs index 7506f33..1c52cfc 100644 --- a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs +++ b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs @@ -65,7 +65,8 @@ public async Task ExecuteAsync( ToolOriginatingPluginId: pluginSource?.PluginId, ToolOriginatingPluginTrust: pluginSource?.Trust, PrimaryMode: context.PrimaryMode, - TenantId: hostContextAccessor?.Current?.TenantId); + TenantId: hostContextAccessor?.Current?.TenantId, + ApprovalSettings: context.ApprovalSettings); var publishOptions = CreatePublishOptions(context); var now = DateTimeOffset.UtcNow; diff --git a/src/SharpClaw.Code.Tools/Models/ToolExecutionContext.cs b/src/SharpClaw.Code.Tools/Models/ToolExecutionContext.cs index 583f5f8..c21f21c 100644 --- a/src/SharpClaw.Code.Tools/Models/ToolExecutionContext.cs +++ b/src/SharpClaw.Code.Tools/Models/ToolExecutionContext.cs @@ -1,5 +1,6 @@ using SharpClaw.Code.Permissions.Models; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.Tools.Models; @@ -14,6 +15,9 @@ namespace SharpClaw.Code.Tools.Models; /// The active permission mode. /// The preferred output format. /// Optional environment variables for subprocess tools. +/// The active model identifier for the parent agent, if any. +/// The parent agent id for agent-originated tool calls, if any. +/// Additional parent-agent metadata forwarded to internal tool orchestration. /// The explicitly allowed tools, if tool execution is restricted. /// Indicates whether dangerous shell approval can be bypassed explicitly. /// Indicates whether approval prompts can interact with the caller. @@ -23,6 +27,7 @@ namespace SharpClaw.Code.Tools.Models; /// The trusted MCP server names for the current session. /// Workflow mode forwarded to permission evaluation. /// Optional recorder for reversible workspace file mutations. +/// Optional bounded auto-approval settings forwarded to permission evaluation. public sealed record ToolExecutionContext( string SessionId, string TurnId, @@ -31,6 +36,9 @@ public sealed record ToolExecutionContext( PermissionMode PermissionMode, OutputFormat OutputFormat, IReadOnlyDictionary? EnvironmentVariables, + string? Model = null, + string? AgentId = null, + IReadOnlyDictionary? Metadata = null, IReadOnlyCollection? AllowedTools = null, bool AllowDangerousBypass = false, bool IsInteractive = true, @@ -39,4 +47,5 @@ public sealed record ToolExecutionContext( IReadOnlyCollection? TrustedPluginNames = null, IReadOnlyCollection? TrustedMcpServerNames = null, PrimaryMode PrimaryMode = PrimaryMode.Build, - IToolMutationRecorder? MutationRecorder = null); + IToolMutationRecorder? MutationRecorder = null, + ApprovalSettings? ApprovalSettings = null); diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/PlanModeWorkflowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/PlanModeWorkflowTests.cs new file mode 100644 index 0000000..41ea1aa --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/PlanModeWorkflowTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// +/// Verifies deep plan-mode prompt execution and todo synchronization. +/// +public sealed class PlanModeWorkflowTests +{ + /// + /// Ensures plan mode returns structured output and synchronizes planning-owned session todos. + /// + [Fact] + public async Task RunPrompt_plan_mode_should_create_structured_plan_and_sync_session_todos() + { + var workspacePath = CreateTemporaryWorkspace(); + using var serviceProvider = CreateRuntimeServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + var runtime = serviceProvider.GetRequiredService(); + var todoService = serviceProvider.GetRequiredService(); + + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "Plan the next implementation slice for worktree automation", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "plan-provider", + ["model"] = "plan-model" + }, + PrimaryMode: PrimaryMode.Plan), + CancellationToken.None); + + result.PlanResult.Should().NotBeNull(); + result.FinalOutput.Should().Contain("Deep plan ready."); + result.PlanResult!.Tasks.Should().HaveCount(2); + result.Session.Metadata.Should().ContainKey(SharpClawWorkflowMetadataKeys.DeepPlanningSummary); + + var todos = await todoService.GetSnapshotAsync(workspacePath, result.Session.Id, CancellationToken.None); + todos.SessionTodos.Should().ContainSingle(item => item.Title == "PLAN-001: Audit the current git/worktree seams" && item.OwnerAgentId == "deep-planning"); + todos.SessionTodos.Should().ContainSingle(item => item.Title == "PLAN-002: Wire worktree creation into session orchestration" && item.Status == TodoStatus.InProgress); + } + + private static string CreateTemporaryWorkspace() + { + var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-plan-mode-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspacePath); + return workspacePath; + } + + private static ServiceProvider CreateRuntimeServices(Action configure) + { + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + configure(services); + return services.BuildServiceProvider(); + } + + private sealed class PassthroughPreflight : IProviderRequestPreflight + { + public ProviderRequest Prepare(ProviderRequest request) => request; + } + + private sealed class AlwaysAuthenticatedAuthFlowService : IAuthFlowService + { + public Task GetStatusAsync(string providerName, CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("plan-subject", true, providerName, null, null, ["plan"])); + } + + private sealed class PlanModelProviderResolver : IModelProviderResolver + { + public IModelProvider Resolve(string providerName) => new PlanModelProvider(); + } + + private sealed class PlanModelProvider : IModelProvider + { + public string ProviderName => "plan-provider"; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("plan-subject", true, ProviderName, null, null, ["plan"])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request))); + + private static async IAsyncEnumerable StreamEventsAsync(ProviderRequest request) + { + yield return new ProviderEvent( + "plan-event-1", + request.Id, + "delta", + DateTimeOffset.Parse("2026-04-21T00:00:00Z"), + """ + { + "summary": "Capture the current worktree behavior, then wire creation paths into the session flow incrementally.", + "assumptions": [ + "Git is available in the target workspace." + ], + "risks": [ + "Worktree creation can collide with existing paths or branch names." + ], + "nextAction": "Audit the existing git service and session orchestration seams before editing.", + "tasks": [ + { + "id": "PLAN-001", + "title": "Audit the current git/worktree seams", + "status": "open", + "details": "Trace how git context is assembled and where worktree commands belong.", + "doneCriteria": "A clear integration path is identified." + }, + { + "id": "PLAN-002", + "title": "Wire worktree creation into session orchestration", + "status": "inProgress", + "details": "Add the command/runtime path that can create isolated worktrees for future session flows.", + "doneCriteria": "A user-facing command can create and inspect worktrees." + } + ] + } + """, + false, + null); + await Task.Yield(); + yield return new ProviderEvent( + "plan-event-2", + request.Id, + "completed", + DateTimeOffset.Parse("2026-04-21T00:00:01Z"), + null, + true, + new UsageSnapshot(12, 24, 0, 36, null)); + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs index 474fec1..bfb02a6 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs @@ -33,8 +33,14 @@ public sealed class PromptContextAssemblyTests public async Task RunPrompt_should_include_memory_skills_and_git_context_in_provider_request() { var workspacePath = CreateTemporaryWorkspace(); + Directory.CreateDirectory(Path.Combine(workspacePath, "rules")); + Directory.CreateDirectory(Path.Combine(workspacePath, ".sharpclaw", "rules")); + await File.WriteAllTextAsync(Path.Combine(workspacePath, "rules", "global.md"), "Prefer durable state.", CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(workspacePath, ".sharpclaw", "rules", "workspace.md"), "Prefer typed contracts.", CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(workspacePath, "AGENTS.md"), "Prefer explicit orchestration.", CancellationToken.None); var services = new ServiceCollection(); services.AddSharpClawRuntime(); + services.AddSingleton(new FixedUserProfilePaths(workspacePath, new PathService())); services.AddSingleton(new StubProjectMemoryService()); services.AddSingleton(new StubSessionSummaryService()); services.AddSingleton(new StubSkillRegistry()); @@ -67,6 +73,10 @@ public async Task RunPrompt_should_include_memory_skills_and_git_context_in_prov providerStarted.Request.Prompt.Should().Contain("Git context"); providerStarted.Request.Prompt.Should().Contain("Branch: main"); providerStarted.Request.Prompt.Should().Contain("Session summary"); + providerStarted.Request.Prompt.Should().Contain("Persistent rules"); + providerStarted.Request.Prompt.Should().Contain("Prefer durable state."); + providerStarted.Request.Prompt.Should().Contain("Prefer typed contracts."); + providerStarted.Request.Prompt.Should().Contain("Prefer explicit orchestration."); } /// @@ -80,6 +90,7 @@ public async Task RunPrompt_should_reuse_in_process_conversation_history_cache() var countingEventStore = new CountingEventStore(new NdjsonEventStore(new LocalFileSystem(), CreateStoragePathResolver(workspacePath, pathService))); var services = new ServiceCollection(); services.AddSharpClawRuntime(); + services.AddSingleton(new FixedUserProfilePaths(workspacePath, new PathService())); services.AddSingleton(new StubProjectMemoryService()); services.AddSingleton(new StubSessionSummaryService()); services.AddSingleton(new StubSkillRegistry()); @@ -191,7 +202,41 @@ public Task GetSnapshotAsync(string workingDirectory, Canc BranchFreshness: new GitBranchFreshness(true, 0, 0), StatusEntries: [], StatusSummary: "Clean working tree.", - DiffSummary: "No pending diff.")); + DiffSummary: "No pending diff.", + IsLinkedWorktree: false, + MainWorktreePath: workingDirectory, + WorktreeCount: 1)); + + public Task ListWorktreesAsync(string workingDirectory, CancellationToken cancellationToken) + => Task.FromResult(new GitWorktreeList( + RepositoryRoot: workingDirectory, + MainWorktreePath: workingDirectory, + Worktrees: + [ + new GitWorktreeEntry( + Path: workingDirectory, + Branch: "main", + HeadCommitSha: "abc123", + IsCurrent: true, + IsLocked: false, + IsPrunable: false, + IsDetached: false, + IsBare: false, + LockReason: null, + PrunableReason: null) + ])); + + public Task CreateWorktreeAsync( + string workingDirectory, + string path, + string branchName, + string? startPoint, + bool useExistingBranch, + CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task PruneWorktreesAsync(string workingDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); } private sealed class PassthroughPreflight : IProviderRequestPreflight diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptInteractivityFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptInteractivityFlowTests.cs index 4d55348..f23fe8f 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptInteractivityFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptInteractivityFlowTests.cs @@ -70,6 +70,34 @@ await act.Should().ThrowAsync() .WithMessage("*non-interactive*"); } + /// + /// Ensures non-interactive callers can allow prompt-reference reads through bounded auto-approval. + /// + [Fact] + public async Task ExecutePromptAsync_should_allow_outside_workspace_reference_when_prompt_read_is_auto_approved() + { + var workspacePath = CreateTemporaryWorkspace(); + var outsideFile = CreateOutsideWorkspaceFile("auto-approved content"); + var provider = new CapturingPromptProvider(); + using var serviceProvider = CreateServiceProvider(provider); + var runtime = serviceProvider.GetRequiredService(); + + var result = await runtime.ExecutePromptAsync( + $"inspect @{outsideFile}", + new RuntimeCommandContext( + WorkingDirectory: workspacePath, + Model: "prompt-model", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + IsInteractive: false, + ApprovalSettings: new ApprovalSettings([ApprovalScope.PromptOutsideWorkspaceRead], 1)), + CancellationToken.None); + + var providerStarted = result.Events.OfType().Single(); + providerStarted.Request.Prompt.Should().Contain("auto-approved content"); + provider.CapturedRequests.Should().ContainSingle(); + } + private static ServiceProvider CreateServiceProvider(CapturingPromptProvider provider, IApprovalService? approvalService = null) { var services = new ServiceCollection(); diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/SubAgentOrchestrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/SubAgentOrchestrationTests.cs new file mode 100644 index 0000000..eb6b4ab --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/SubAgentOrchestrationTests.cs @@ -0,0 +1,133 @@ +using System.Runtime.CompilerServices; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// +/// Verifies bounded delegated subagent execution flows through the main runtime turn. +/// +public sealed class SubAgentOrchestrationTests +{ + /// + /// Ensures the primary agent can delegate a bounded read-only investigation to the subagent worker. + /// + [Fact] + public async Task RunPrompt_should_execute_subagent_tool_calls_and_emit_child_agent_events() + { + var workspacePath = CreateTemporaryWorkspace(); + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => new StaticModelProviderResolver(new SubAgentScenarioProvider())); + using var serviceProvider = services.BuildServiceProvider(); + + var runtime = serviceProvider.GetRequiredService(); + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "Investigate the auth flow with a helper agent.", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "subagent-provider", + ["model"] = "subagent-model" + }), + CancellationToken.None); + + result.FinalOutput.Should().Contain("Primary agent integrated subagent output."); + result.ToolResults.Should().ContainSingle(tool => tool.ToolName == "use_subagents" && tool.Succeeded); + result.Events.OfType().Should().Contain(spawned => + spawned.AgentKind == "subAgent" && + spawned.ParentAgentId == "primary-coding-agent"); + result.Events.OfType().Should().Contain(completed => + completed.AgentId == "sub-agent-worker" && + completed.Succeeded); + } + + private static string CreateTemporaryWorkspace() + { + var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-subagent-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspacePath); + File.WriteAllText(Path.Combine(workspacePath, "Program.cs"), "class Program {}"); + return workspacePath; + } + + private sealed class PassthroughPreflight : IProviderRequestPreflight + { + public ProviderRequest Prepare(ProviderRequest request) => request; + } + + private sealed class AlwaysAuthenticatedAuthFlowService : IAuthFlowService + { + public Task GetStatusAsync(string providerName, CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("subagent-subject", true, providerName, null, null, ["api"])); + } + + private sealed class StaticModelProviderResolver(IModelProvider provider) : IModelProviderResolver + { + public IModelProvider Resolve(string providerName) => provider; + } + + private sealed class SubAgentScenarioProvider : IModelProvider + { + public string ProviderName => "subagent-provider"; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus("subagent-subject", true, ProviderName, null, null, ["api"])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request, cancellationToken))); + + private static async IAsyncEnumerable StreamEventsAsync( + ProviderRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request.SystemPrompt?.Contains("bounded SharpClaw sub-agent", StringComparison.Ordinal) == true) + { + yield return new ProviderEvent("child-1", request.Id, "delta", DateTimeOffset.UtcNow, "Subagent investigation complete.", false, null); + await Task.Yield(); + yield return new ProviderEvent("child-2", request.Id, "done", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); + yield break; + } + + if (HasToolResultInMessages(request)) + { + yield return new ProviderEvent("parent-2", request.Id, "delta", DateTimeOffset.UtcNow, "Primary agent integrated subagent output.", false, null); + await Task.Yield(); + yield return new ProviderEvent("parent-3", request.Id, "done", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); + yield break; + } + + yield return new ProviderEvent( + "parent-1", + request.Id, + "tool_use", + DateTimeOffset.UtcNow, + null, + false, + null, + BlockType: "tool_use", + ToolUseId: "toolu_subagent_001", + ToolName: "use_subagents", + ToolInputJson: """{"tasks":[{"goal":"Inspect the auth entry point","expectedOutput":"Return concise findings","constraints":["Stay read-only"]}]}"""); + await Task.Yield(); + yield return new ProviderEvent("parent-1-done", request.Id, "done", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); + } + + private static bool HasToolResultInMessages(ProviderRequest request) + => request.Messages is not null + && request.Messages.SelectMany(static message => message.Content).Any(static block => block.Kind == ContentBlockKind.ToolResult); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs index 5fd719e..5d4f108 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs @@ -53,6 +53,7 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "doctor", "index", "unshare", + "worktree", "version", "repl" ]); @@ -63,6 +64,9 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "--cwd", "--model", "--permission-mode", + "--auto-approve", + "--auto-approve-budget", + "--yolo", "--primary-mode", "--session", "--agent", diff --git a/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs b/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs index fd63e75..a49887f 100644 --- a/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs @@ -1,7 +1,10 @@ using FluentAssertions; +using SharpClaw.Code.Agents.Abstractions; using SharpClaw.Code.Agents.Internal; +using SharpClaw.Code.Agents.Models; using SharpClaw.Code.Permissions.Models; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Tools.Abstractions; using SharpClaw.Code.Tools.Models; @@ -25,7 +28,7 @@ public async Task DispatchAsync_executes_tool_and_returns_result_block() var envelope = BuildEnvelope("read_file", result); var executor = new StubToolExecutor { ReturnValue = envelope }; - var dispatcher = new ToolCallDispatcher(executor); + var dispatcher = new ToolCallDispatcher(executor, new StubSubAgentOrchestrator()); var providerEvent = BuildToolUseEvent("tool-use-id-1", "read_file", "{}"); var context = BuildContext(); @@ -50,7 +53,7 @@ public async Task DispatchAsync_returns_error_block_on_tool_failure() var envelope = BuildEnvelope("write_file", result); var executor = new StubToolExecutor { ReturnValue = envelope }; - var dispatcher = new ToolCallDispatcher(executor); + var dispatcher = new ToolCallDispatcher(executor, new StubSubAgentOrchestrator()); var providerEvent = BuildToolUseEvent("tool-use-id-2", "write_file", "{\"path\":\"x\"}"); var context = BuildContext(); @@ -74,7 +77,7 @@ public async Task DispatchAsync_returns_empty_events_to_avoid_duplicates() var result = new ToolResult("req-3", "bash", true, OutputFormat.Text, "done", null, 0, 50, null); var envelope = BuildEnvelope("bash", result); var executor = new StubToolExecutor { ReturnValue = envelope }; - var dispatcher = new ToolCallDispatcher(executor); + var dispatcher = new ToolCallDispatcher(executor, new StubSubAgentOrchestrator()); var providerEvent = BuildToolUseEvent("tool-use-id-3", "bash", "{\"command\":\"ls\"}"); var context = BuildContext(); @@ -92,7 +95,7 @@ public async Task DispatchAsync_returns_empty_events_to_avoid_duplicates() public async Task DispatchAsync_returns_error_when_tool_name_missing() { var executor = new StubToolExecutor(); - var dispatcher = new ToolCallDispatcher(executor); + var dispatcher = new ToolCallDispatcher(executor, new StubSubAgentOrchestrator()); var providerEvent = BuildToolUseEvent("tool-use-id-4", null!, "{}"); var context = BuildContext(); @@ -104,6 +107,45 @@ public async Task DispatchAsync_returns_error_when_tool_name_missing() resultBlock.Text.Should().Contain("tool name"); } + /// + /// Ensures delegated subagent tool calls return the orchestrator payload and runtime events. + /// + [Fact] + public async Task DispatchAsync_handles_subagent_tool_calls() + { + var executor = new StubToolExecutor(); + RuntimeEvent[] subAgentEvents = + [ + new AgentSpawnedEvent("event-1", "s1", "t1", DateTimeOffset.UtcNow, "sub-agent-worker", "subAgent", "primary-coding-agent") + ]; + var orchestrator = new StubSubAgentOrchestrator + { + ReturnValue = new SubAgentBatchExecutionResult( + new SubAgentBatchResult( + [ + new SubAgentTaskResult("task-1", "Inspect auth flow", "Return concise findings", true, "Found the auth entry point.", null, "sub-agent-worker") + ], + CompletedCount: 1, + FailedCount: 0), + subAgentEvents) + }; + var dispatcher = new ToolCallDispatcher(executor, orchestrator); + + var providerEvent = BuildToolUseEvent( + "tool-use-id-5", + "use_subagents", + """{"tasks":[{"goal":"Inspect auth flow","expectedOutput":"Return concise findings"}]}"""); + var context = BuildContext(); + + var (resultBlock, toolResult, events) = await dispatcher.DispatchAsync(providerEvent, context, CancellationToken.None); + + resultBlock.IsError.Should().BeNull(); + resultBlock.Text.Should().Contain("Inspect auth flow"); + toolResult.ToolName.Should().Be("use_subagents"); + toolResult.Succeeded.Should().BeTrue(); + events.Should().ContainSingle(ev => ev is AgentSpawnedEvent); + } + private static ProviderEvent BuildToolUseEvent(string toolUseId, string toolName, string toolInputJson) => new( Id: "pev-1", @@ -163,4 +205,16 @@ public Task ExecuteAsync( => Task.FromResult(ReturnValue!); } + private sealed class StubSubAgentOrchestrator : ISubAgentOrchestrator + { + public SubAgentBatchExecutionResult? ReturnValue { get; set; } + + public Task ExecuteAsync( + SubAgentBatchRequest request, + ToolExecutionContext context, + CancellationToken cancellationToken) + => Task.FromResult(ReturnValue ?? new SubAgentBatchExecutionResult( + new SubAgentBatchResult([], 0, 0), + [])); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs index 654c024..a9cb36e 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs @@ -52,6 +52,40 @@ public void Global_cli_options_should_parse_embedded_host_context() IsEmbeddedHost: true)); } + [Fact] + public void Global_cli_options_should_force_danger_full_access_when_yolo_is_set() + { + var options = new GlobalCliOptions(); + var command = new RootCommand(); + foreach (var option in options.All) + { + command.Options.Add(option); + } + + var parseResult = command.Parse("-y"); + var context = options.Resolve(parseResult); + + context.PermissionMode.Should().Be(PermissionMode.DangerFullAccess); + } + + [Fact] + public void Global_cli_options_should_parse_auto_approve_settings_and_budget() + { + var options = new GlobalCliOptions(); + var command = new RootCommand(); + foreach (var option in options.All) + { + command.Options.Add(option); + } + + var parseResult = command.Parse("--auto-approve shell,network --auto-approve-budget 4"); + var context = options.Resolve(parseResult); + + context.ApprovalSettings.Should().BeEquivalentTo(new ApprovalSettings( + [ApprovalScope.ShellExecution, ApprovalScope.NetworkAccess], + 4)); + } + [Fact] public async Task Mode_slash_command_should_set_spec_mode() { @@ -76,6 +110,32 @@ public async Task Mode_slash_command_should_set_spec_mode() renderer.LastCommandResult!.Message.Should().Contain("Primary mode set to Spec"); } + [Fact] + public async Task Approvals_slash_command_should_set_auto_approve_override() + { + var replState = new ReplInteractionState(); + var renderer = new StubOutputRenderer(); + var handler = new ApprovalsSlashCommandHandler(replState, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext( + WorkingDirectory: "/workspace", + Model: null, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + PrimaryMode: PrimaryMode.Build, + SessionId: null); + + var exitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "approvals", ["set", "shell,promptRead", "2"]), + context, + CancellationToken.None); + + exitCode.Should().Be(0); + replState.ApprovalSettingsOverride.Should().BeEquivalentTo(new ApprovalSettings( + [ApprovalScope.ShellExecution, ApprovalScope.PromptOutsideWorkspaceRead], + 2)); + renderer.LastCommandResult!.Message.Should().Contain("Auto-approval override set"); + } + private sealed class StubOutputRenderer : IOutputRenderer { public OutputFormat Format => OutputFormat.Text; diff --git a/tests/SharpClaw.Code.UnitTests/Commands/PromptInvocationServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/PromptInvocationServiceTests.cs new file mode 100644 index 0000000..bbeb308 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Commands/PromptInvocationServiceTests.cs @@ -0,0 +1,169 @@ +using FluentAssertions; +using SharpClaw.Code.Commands; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.UnitTests.Commands; + +/// +/// Verifies one-shot prompt execution composes stdin and routing flags correctly. +/// +public sealed class PromptInvocationServiceTests +{ + [Fact] + public async Task ExecuteAsync_should_use_piped_stdin_when_prompt_tokens_are_empty() + { + var runtime = new RecordingRuntimeCommandService(); + var renderer = new RecordingRenderer(); + var service = new PromptInvocationService( + runtime, + new OutputRendererDispatcher([renderer]), + new StubCliInvocationEnvironment("review the incoming diff", isInputRedirected: true)); + var context = new CommandExecutionContext("/workspace", "model-a", PermissionMode.WorkspaceWrite, OutputFormat.Text, PrimaryMode.Build); + + var exitCode = await service.ExecuteAsync([], context, forceNonInteractive: true, CancellationToken.None); + + exitCode.Should().Be(0); + runtime.LastPrompt.Should().Be("review the incoming diff"); + runtime.LastContext!.IsInteractive.Should().BeFalse(); + renderer.LastTurnResult.Should().NotBeNull(); + } + + [Fact] + public async Task ExecuteAsync_should_combine_piped_input_with_prompt_tokens() + { + var runtime = new RecordingRuntimeCommandService(); + var service = new PromptInvocationService( + runtime, + new OutputRendererDispatcher([new RecordingRenderer()]), + new StubCliInvocationEnvironment("namespace Example;", isInputRedirected: true)); + var context = new CommandExecutionContext("/workspace", "model-a", PermissionMode.WorkspaceWrite, OutputFormat.Text, PrimaryMode.Build); + + var exitCode = await service.ExecuteAsync(["Summarize", "this", "file"], context, forceNonInteractive: true, CancellationToken.None); + + exitCode.Should().Be(0); + runtime.LastPrompt.Should().Contain("Piped input:"); + runtime.LastPrompt.Should().Contain("namespace Example;"); + runtime.LastPrompt.Should().Contain("User request:"); + runtime.LastPrompt.Should().Contain("Summarize this file"); + } + + [Fact] + public async Task ExecuteAsync_should_render_error_when_no_prompt_is_available() + { + var renderer = new RecordingRenderer(); + var service = new PromptInvocationService( + new RecordingRuntimeCommandService(), + new OutputRendererDispatcher([renderer]), + new StubCliInvocationEnvironment(string.Empty, isInputRedirected: false)); + var context = new CommandExecutionContext("/workspace", "model-a", PermissionMode.WorkspaceWrite, OutputFormat.Text, PrimaryMode.Build); + + var exitCode = await service.ExecuteAsync([], context, forceNonInteractive: false, CancellationToken.None); + + exitCode.Should().Be(1); + renderer.LastCommandResult!.Message.Should().Contain("No prompt text was provided"); + } + + private sealed class RecordingRuntimeCommandService : IRuntimeCommandService + { + public string? LastPrompt { get; private set; } + + public RuntimeCommandContext? LastContext { get; private set; } + + public Task ExecutePromptAsync(string prompt, RuntimeCommandContext context, CancellationToken cancellationToken) + { + LastPrompt = prompt; + LastContext = context; + return Task.FromResult(new TurnExecutionResult( + new ConversationSession("session-1", "Session", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Text, "/workspace", "/workspace", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null), + new ConversationTurn("turn-1", "session-1", 1, prompt, "ok", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "primary-coding-agent", null, null, null), + "ok", + [], + null, + null, + [])); + } + + public Task ExecuteCustomCommandAsync(string commandName, string arguments, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task GetStatusAsync(RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RunDoctorAsync(RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InspectSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ForkSessionAsync(string? sourceSessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ExportSessionAsync(string? sessionId, SessionExportFormat format, string? outputFilePath, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UndoAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RedoAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ExportPortableSessionBundleAsync(string? sessionId, string? outputZipPath, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ImportPortableSessionBundleAsync(string bundleZipPath, bool replaceExisting, bool attachAfterImport, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ListSessionsAsync(RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task AttachSessionAsync(string sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task DetachSessionAsync(RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ShareSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UnshareSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task CompactSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class StubCliInvocationEnvironment(string stdin, bool isInputRedirected, bool isOutputRedirected = false) : ICliInvocationEnvironment + { + public bool IsInputRedirected => isInputRedirected; + + public bool IsOutputRedirected => isOutputRedirected; + + public Task ReadStandardInputToEndAsync(CancellationToken cancellationToken) + => Task.FromResult(stdin); + } + + private sealed class RecordingRenderer : IOutputRenderer + { + public OutputFormat Format => OutputFormat.Text; + + public CommandResult? LastCommandResult { get; private set; } + + public TurnExecutionResult? LastTurnResult { get; private set; } + + public Task RenderCommandResultAsync(CommandResult result, CancellationToken cancellationToken) + { + LastCommandResult = result; + return Task.CompletedTask; + } + + public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, CancellationToken cancellationToken) + { + LastTurnResult = result; + return Task.CompletedTask; + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs index 709a6e9..57eb969 100644 --- a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs @@ -99,9 +99,31 @@ public async Task GitWorkspaceService_should_parse_status_diff_and_branch_freshn snapshot.BranchFreshness.BehindBy.Should().Be(1); snapshot.StatusEntries.Should().ContainSingle(entry => entry.Path == "src/Changed.cs"); snapshot.DiffSummary.Should().Contain("1 file changed"); + snapshot.WorktreeCount.Should().Be(2); + snapshot.IsLinkedWorktree.Should().BeFalse(); snapshot.RenderForPrompt().Should().Contain("Branch: main"); } + /// + /// Ensures worktree listing, creation, and pruning flow through the git workspace service. + /// + [Fact] + public async Task GitWorkspaceService_should_list_create_and_prune_worktrees() + { + var runner = new StubGitProcessRunner(); + IGitWorkspaceService service = new GitWorkspaceService(runner); + + var initial = await service.ListWorktreesAsync("/repo", CancellationToken.None); + var created = await service.CreateWorktreeAsync("/repo", "../repo-new", "feature/new", "HEAD", useExistingBranch: false, CancellationToken.None); + var pruned = await service.PruneWorktreesAsync("/repo", CancellationToken.None); + + initial.Worktrees.Should().HaveCount(2); + created.Worktree.Path.Should().Be("/repo-new"); + created.Worktree.Branch.Should().Be("feature/new"); + pruned.PrunedCount.Should().Be(1); + pruned.Worktrees.Should().NotContain(entry => entry.IsPrunable); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-memory-skill-git-tests", Guid.NewGuid().ToString("N")); @@ -116,20 +138,69 @@ private sealed class FixedClock : ISystemClock private sealed class StubGitProcessRunner : IProcessRunner { + private readonly Dictionary worktrees = new(StringComparer.Ordinal) + { + ["/repo"] = new("main", "abc123", IsPrunable: false), + ["/repo-linked"] = new("feature/worktrees", "def456", IsPrunable: true), + }; + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) { var output = request.Arguments switch { ["rev-parse", "--show-toplevel"] => "/repo\n", + ["rev-parse", "--path-format=absolute", "--git-common-dir"] => "/repo/.git\n", ["branch", "--show-current"] => "main\n", ["rev-parse", "HEAD"] => "abc123\n", ["status", "--porcelain=v1", "--branch"] => "## main...origin/main\n M src/Changed.cs\n", ["diff", "--no-ext-diff", "--stat"] => " src/Changed.cs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n", ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"] => "1\t2\n", + ["worktree", "list", "--porcelain"] => RenderWorktreeList(), + ["worktree", "add", "-b", _, _, ..] => CreateWorktree(request.Arguments), + ["worktree", "prune"] => PruneWorktrees(), _ => throw new InvalidOperationException($"Unexpected git command: {string.Join(' ', request.Arguments)}") }; return Task.FromResult(new ProcessRunResult(0, output, string.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); } + + private string CreateWorktree(string[] arguments) + { + var branch = arguments[3]; + var path = arguments[4]; + worktrees[path] = new WorktreeState(branch, "fedcba", IsPrunable: false); + return $"Preparing worktree (new branch '{branch}')\nHEAD is now at fedcba\n"; + } + + private string PruneWorktrees() + { + foreach (var prunable in worktrees.Where(static pair => pair.Value.IsPrunable).Select(static pair => pair.Key).ToArray()) + { + worktrees.Remove(prunable); + } + + return "Pruned stale worktrees.\n"; + } + + private string RenderWorktreeList() + { + var builder = new System.Text.StringBuilder(); + foreach (var pair in worktrees.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + builder.Append("worktree ").AppendLine(pair.Key); + builder.Append("HEAD ").AppendLine(pair.Value.Head); + builder.Append("branch refs/heads/").AppendLine(pair.Value.Branch); + if (pair.Value.IsPrunable) + { + builder.AppendLine("prunable missing path"); + } + + builder.AppendLine(); + } + + return builder.ToString(); + } + + private sealed record WorktreeState(string Branch, string Head, bool IsPrunable); } } diff --git a/tests/SharpClaw.Code.UnitTests/Operational/OperationalReportsJsonTests.cs b/tests/SharpClaw.Code.UnitTests/Operational/OperationalReportsJsonTests.cs index e9c917d..ad7682a 100644 --- a/tests/SharpClaw.Code.UnitTests/Operational/OperationalReportsJsonTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Operational/OperationalReportsJsonTests.cs @@ -46,6 +46,7 @@ public void RuntimeStatusReport_round_trips_through_JsonContext() RuntimeState: "ready", SelectedModel: "deterministic", PermissionMode: PermissionMode.ReadOnly, + ApprovalSettings: new ApprovalSettings([ApprovalScope.ToolExecution], 3), PrimaryMode: PrimaryMode.Build, LatestSessionId: "sess-1", LatestSessionState: SessionLifecycleState.Active, @@ -63,6 +64,7 @@ public void RuntimeStatusReport_round_trips_through_JsonContext() var restored = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.RuntimeStatusReport); restored.Should().NotBeNull(); restored!.LatestSessionState.Should().Be(SessionLifecycleState.Active); + restored.ApprovalSettings.Should().NotBeNull(); restored.McpFaultedCount.Should().Be(1); restored.LspServerCount.Should().Be(1); restored.DiagnosticsErrorCount.Should().Be(2); diff --git a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs index 7f9c770..a6e59ae 100644 --- a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs @@ -280,6 +280,58 @@ public async Task EvaluateAsync_spec_primary_mode_allows_workspace_writes() decision.IsAllowed.Should().BeTrue(); } + /// + /// Ensures configured auto-approval allows matching scopes without prompting the user. + /// + [Fact] + public async Task EvaluateAsync_should_auto_approve_matching_scope_without_prompt() + { + var approvalService = new StubApprovalService(); + var engine = CreateEngine(approvalService); + var context = CreateContext( + PermissionMode.WorkspaceWrite, + isInteractive: false, + approvalSettings: new ApprovalSettings([ApprovalScope.ShellExecution], 2)); + var request = CreateRequest( + "bash", + """{"command":"git status","workingDirectory":".","environmentVariables":null}""", + ApprovalScope.ShellExecution, + requiresApproval: true, + isDestructive: true); + + var decision = await engine.EvaluateAsync(request, context, CancellationToken.None); + + decision.IsAllowed.Should().BeTrue(); + decision.Reason.Should().Contain("Auto-approved"); + approvalService.RequestCount.Should().Be(0); + } + + /// + /// Ensures auto-approval budget exhaustion falls back to explicit approval flow. + /// + [Fact] + public async Task EvaluateAsync_should_fall_back_to_prompt_when_auto_approve_budget_is_exhausted() + { + var approvalService = new StubApprovalService(); + var engine = CreateEngine(approvalService); + var context = CreateContext( + PermissionMode.WorkspaceWrite, + approvalSettings: new ApprovalSettings([ApprovalScope.ShellExecution], 1)); + var request = CreateRequest( + "bash", + """{"command":"git status","workingDirectory":".","environmentVariables":null}""", + ApprovalScope.ShellExecution, + requiresApproval: true, + isDestructive: true); + + var firstDecision = await engine.EvaluateAsync(request, context, CancellationToken.None); + var secondDecision = await engine.EvaluateAsync(request, context, CancellationToken.None); + + firstDecision.IsAllowed.Should().BeTrue(); + secondDecision.IsAllowed.Should().BeTrue(); + approvalService.RequestCount.Should().Be(1); + } + private static IPermissionPolicyEngine CreateEngine(IApprovalService approvalService) => new PermissionPolicyEngine( [ @@ -291,7 +343,8 @@ private static IPermissionPolicyEngine CreateEngine(IApprovalService approvalSer new McpTrustRule() ], approvalService, - new SessionApprovalMemory()); + new SessionApprovalMemory(), + new AutoApprovalBudgetTracker()); private static PermissionEvaluationContext CreateContext( PermissionMode permissionMode, @@ -304,7 +357,8 @@ private static PermissionEvaluationContext CreateContext( string[]? trustedMcpServers = null, string? toolOriginatingPluginId = null, PluginTrustLevel? toolOriginatingPluginTrust = null, - PrimaryMode primaryMode = PrimaryMode.Build) + PrimaryMode primaryMode = PrimaryMode.Build, + ApprovalSettings? approvalSettings = null) => new( SessionId: "session-001", WorkspaceRoot: "/workspace", @@ -319,7 +373,8 @@ private static PermissionEvaluationContext CreateContext( TrustedMcpServerNames: trustedMcpServers, ToolOriginatingPluginId: toolOriginatingPluginId, ToolOriginatingPluginTrust: toolOriginatingPluginTrust, - PrimaryMode: primaryMode); + PrimaryMode: primaryMode, + ApprovalSettings: approvalSettings); private static ToolExecutionRequest CreateRequest( string toolName, diff --git a/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs b/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs index c2a177e..7efcb18 100644 --- a/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Protocol/ProtocolJsonContextTests.cs @@ -120,4 +120,67 @@ public void Turn_execution_result_should_serialize_spec_artifacts() json.Should().Contain("\"specArtifacts\":"); json.Should().Contain("\"slug\":\"my-spec\""); } + + /// + /// Ensures plan-mode execution metadata serializes through the shared protocol context. + /// + [Fact] + public void Turn_execution_result_should_serialize_plan_result() + { + var result = new TurnExecutionResult( + Session: new ConversationSession( + Id: "session-001", + Title: "Plan session", + State: SessionLifecycleState.Active, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json, + WorkingDirectory: "/workspace", + RepositoryRoot: "/workspace", + CreatedAtUtc: DateTimeOffset.Parse("2026-04-21T00:00:00Z"), + UpdatedAtUtc: DateTimeOffset.Parse("2026-04-21T00:05:00Z"), + ActiveTurnId: null, + LastCheckpointId: null, + Metadata: []), + Turn: new ConversationTurn( + Id: "turn-001", + SessionId: "session-001", + SequenceNumber: 1, + Input: "plan this", + Output: "Deep plan ready.", + StartedAtUtc: DateTimeOffset.Parse("2026-04-21T00:00:00Z"), + CompletedAtUtc: DateTimeOffset.Parse("2026-04-21T00:05:00Z"), + AgentId: "primary-coding-agent", + SlashCommandName: null, + Usage: null, + Metadata: []), + FinalOutput: "Deep plan ready.", + ToolResults: [], + Usage: null, + Checkpoint: null, + Events: [], + PlanResult: new PlanExecutionResult( + Summary: "Audit current seams before editing.", + Assumptions: ["Git is available."], + Risks: ["Branch naming collisions."], + NextAction: "Inspect the git service.", + Tasks: + [ + new PlanTaskItem("PLAN-001", "Inspect the git service", TodoStatus.Open, null, null) + ], + TodoSync: new ManagedTodoSyncResult( + OwnerAgentId: "deep-planning", + AddedCount: 1, + UpdatedCount: 0, + RemovedCount: 0, + ActiveTodos: + [ + new TodoItem("todo-001", "PLAN-001: Inspect the git service", TodoStatus.Open, TodoScope.Session, DateTimeOffset.Parse("2026-04-21T00:04:00Z"), DateTimeOffset.Parse("2026-04-21T00:04:00Z"), "deep-planning", "session-001") + ]))); + + var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.TurnExecutionResult); + + json.Should().Contain("\"planResult\":"); + json.Should().Contain("\"nextAction\":\"Inspect the git service.\""); + json.Should().Contain("\"ownerAgentId\":\"deep-planning\""); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/PromptReferenceResolverTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/PromptReferenceResolverTests.cs index 6ab2855..32d91a1 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/PromptReferenceResolverTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/PromptReferenceResolverTests.cs @@ -53,7 +53,8 @@ public async Task ResolveAsync_should_reject_symlinked_reference_outside_workspa new McpTrustRule() ], new NonInteractiveApprovalService(), - new SessionApprovalMemory()); + new SessionApprovalMemory(), + new AutoApprovalBudgetTracker()); var resolver = new PromptReferenceResolver(new LocalFileSystem(), pathService, engine); var session = new ConversationSession( "session-001", diff --git a/tests/SharpClaw.Code.UnitTests/Tools/ToolRegistryAndExecutionTests.cs b/tests/SharpClaw.Code.UnitTests/Tools/ToolRegistryAndExecutionTests.cs index 703fc68..3d1346a 100644 --- a/tests/SharpClaw.Code.UnitTests/Tools/ToolRegistryAndExecutionTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Tools/ToolRegistryAndExecutionTests.cs @@ -318,7 +318,8 @@ private static IToolExecutor CreateExecutor(IToolRegistry registry) new McpTrustRule() ], new NonInteractiveApprovalService(), - new SessionApprovalMemory())); + new SessionApprovalMemory(), + new AutoApprovalBudgetTracker())); private static ToolExecutionContext CreateContext(string workspacePath, PermissionMode permissionMode) => new( From 532ec8dddf51342252cd8ebf5a2e8fa53b8b7368 Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 21 Apr 2026 18:06:50 -0700 Subject: [PATCH 2/2] fix review feedback and windows test stability --- .../Handlers/ApprovalsSlashCommandHandler.cs | 2 +- .../Services/GitWorkspaceService.cs | 27 ++++++-- .../Services/LocalFileSystem.cs | 46 ++++++++++--- .../Context/InstructionRuleService.cs | 40 ++++++++++-- .../Workflow/ApprovalSettingsResolver.cs | 2 +- .../Commands/ModeAndCliOptionsTests.cs | 27 ++++++++ .../MemorySkillsGitServiceTests.cs | 47 +++++++++++++- .../Runtime/InstructionRuleServiceTests.cs | 64 +++++++++++++++++++ 8 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/InstructionRuleServiceTests.cs diff --git a/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs index fcb01a0..ce269de 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ApprovalsSlashCommandHandler.cs @@ -33,7 +33,7 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC if (string.Equals(command.Arguments[0], "reset", StringComparison.OrdinalIgnoreCase) || string.Equals(command.Arguments[0], "clear", StringComparison.OrdinalIgnoreCase)) { - replState.ApprovalSettingsOverride = ApprovalSettings.Empty; + replState.ApprovalSettingsOverride = null; return RenderAsync("Auto-approval reset for the next prompt.", context, cancellationToken); } diff --git a/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs b/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs index 0cda572..199c172 100644 --- a/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs +++ b/src/SharpClaw.Code.Git/Services/GitWorkspaceService.cs @@ -162,8 +162,10 @@ private async Task ListWorktreesCoreAsync(string workingDirecto throw new InvalidOperationException(BuildGitFailureMessage("Failed to list git worktrees.", listResult)); } - var mainWorktreePath = ResolveMainWorktreePath(commonDirResult, repositoryRoot); - var worktrees = ParseWorktreeEntries(listResult.StandardOutput, repositoryRoot, mainWorktreePath); + var mainWorktreePath = commonDirResult.ExitCode == 0 + ? ResolveMainWorktreePath(commonDirResult, repositoryRoot) + : repositoryRoot; + var worktrees = ParseWorktreeEntries(listResult.StandardOutput, repositoryRoot); if (worktrees.Count == 0) { worktrees = @@ -251,7 +253,7 @@ private static string[] BuildCreateArguments(string path, string branchName, str : ["worktree", "add", "-b", branchName, path, startPoint.Trim()]; } - private static List ParseWorktreeEntries(string output, string repositoryRoot, string mainWorktreePath) + private static List ParseWorktreeEntries(string output, string repositoryRoot) { var entries = new List(); string? currentPath = null; @@ -376,14 +378,29 @@ private static string ResolveMainWorktreePath(ProcessRunResult commonDirResult, return repositoryRoot; } - if (commonDir.EndsWith($"{Path.DirectorySeparatorChar}.git", OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + var normalizedCommonDir = NormalizeDirectorySeparators(commonDir); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var dotGitSuffix = $"{Path.DirectorySeparatorChar}.git"; + if (normalizedCommonDir.EndsWith(dotGitSuffix, comparison)) { - return Path.GetDirectoryName(commonDir) ?? repositoryRoot; + return Path.GetDirectoryName(normalizedCommonDir) ?? repositoryRoot; + } + + var linkedMarker = $"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}worktrees{Path.DirectorySeparatorChar}"; + var linkedMarkerIndex = normalizedCommonDir.LastIndexOf(linkedMarker, comparison); + if (linkedMarkerIndex > 0) + { + return normalizedCommonDir[..linkedMarkerIndex]; } return repositoryRoot; } + private static string NormalizeDirectorySeparators(string path) + => Path.DirectorySeparatorChar == Path.AltDirectorySeparatorChar + ? path + : path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + private static string? NormalizeBranch(string? branch) { if (string.IsNullOrWhiteSpace(branch)) diff --git a/src/SharpClaw.Code.Infrastructure/Services/LocalFileSystem.cs b/src/SharpClaw.Code.Infrastructure/Services/LocalFileSystem.cs index 1278bcd..07fa8e4 100644 --- a/src/SharpClaw.Code.Infrastructure/Services/LocalFileSystem.cs +++ b/src/SharpClaw.Code.Infrastructure/Services/LocalFileSystem.cs @@ -69,23 +69,53 @@ public IEnumerable EnumerateFiles(string path, string searchPattern) /// public async Task ReadAllTextIfExistsAsync(string path, CancellationToken cancellationToken) { - if (!File.Exists(path)) + for (var attempt = 0; ; attempt++) { - return null; - } + cancellationToken.ThrowIfCancellationRequested(); + if (!File.Exists(path)) + { + return null; + } - return await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + try + { + return await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + } + catch (IOException) when (attempt < MaxLockRetries) + { + await Task.Delay(LockRetryDelay, cancellationToken).ConfigureAwait(false); + } + catch (UnauthorizedAccessException) when (attempt < MaxLockRetries) + { + await Task.Delay(LockRetryDelay, cancellationToken).ConfigureAwait(false); + } + } } /// public async Task ReadAllLinesIfExistsAsync(string path, CancellationToken cancellationToken) { - if (!File.Exists(path)) + for (var attempt = 0; ; attempt++) { - return []; - } + cancellationToken.ThrowIfCancellationRequested(); + if (!File.Exists(path)) + { + return []; + } - return await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false); + try + { + return await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false); + } + catch (IOException) when (attempt < MaxLockRetries) + { + await Task.Delay(LockRetryDelay, cancellationToken).ConfigureAwait(false); + } + catch (UnauthorizedAccessException) when (attempt < MaxLockRetries) + { + await Task.Delay(LockRetryDelay, cancellationToken).ConfigureAwait(false); + } + } } /// diff --git a/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs b/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs index 8065bdb..d50e48e 100644 --- a/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs +++ b/src/SharpClaw.Code.Runtime/Context/InstructionRuleService.cs @@ -171,10 +171,7 @@ private async Task AddFileAsync( } var maxForDocument = Math.Min(MaxDocumentCharacters, remaining); - var isTruncated = normalized.Length > maxForDocument; - var trimmed = isTruncated - ? normalized[..Math.Max(0, maxForDocument - 28)].TrimEnd() + Environment.NewLine + "[Instruction truncated]" - : normalized; + var trimmed = TrimToBudget(normalized, maxForDocument, out var isTruncated); if (string.IsNullOrWhiteSpace(trimmed)) { @@ -230,6 +227,41 @@ private static string NormalizeContent(string content) private static bool BudgetExhausted(List documents, RuleBudget budget) => documents.Count >= MaxDocumentCount || budget.TotalCharacters >= MaxTotalCharacters; + private static string? TrimToBudget(string content, int maxCharacters, out bool isTruncated) + { + isTruncated = false; + if (maxCharacters <= 0) + { + return null; + } + + if (content.Length <= maxCharacters) + { + return content; + } + + isTruncated = true; + const string marker = "[Instruction truncated]"; + var markerWithNewLineLength = marker.Length + Environment.NewLine.Length; + if (maxCharacters > markerWithNewLineLength) + { + var prefixLength = Math.Max(0, maxCharacters - markerWithNewLineLength); + var prefix = content[..prefixLength].TrimEnd(); + if (!string.IsNullOrWhiteSpace(prefix)) + { + return prefix + Environment.NewLine + marker; + } + } + + var fallback = content[..maxCharacters].TrimEnd(); + if (!string.IsNullOrWhiteSpace(fallback)) + { + return fallback; + } + + return marker[..Math.Min(marker.Length, maxCharacters)]; + } + private sealed class RuleBudget { public int TotalCharacters { get; set; } diff --git a/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs b/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs index bac0679..49c4d5a 100644 --- a/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs +++ b/src/SharpClaw.Code.Runtime/Workflow/ApprovalSettingsResolver.cs @@ -31,7 +31,7 @@ public static ApprovalSettings Normalize(ApprovalSettings settings) { ArgumentNullException.ThrowIfNull(settings); - var scopes = settings.AutoApproveScopes + var scopes = (settings.AutoApproveScopes ?? []) .Where(scope => scope != ApprovalScope.None) .Distinct() .OrderBy(static scope => scope) diff --git a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs index a9cb36e..a8bb23a 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs @@ -136,6 +136,33 @@ public async Task Approvals_slash_command_should_set_auto_approve_override() renderer.LastCommandResult!.Message.Should().Contain("Auto-approval override set"); } + [Fact] + public async Task Approvals_slash_command_reset_should_clear_override() + { + var replState = new ReplInteractionState + { + ApprovalSettingsOverride = new ApprovalSettings([ApprovalScope.ShellExecution], 1) + }; + var renderer = new StubOutputRenderer(); + var handler = new ApprovalsSlashCommandHandler(replState, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext( + WorkingDirectory: "/workspace", + Model: null, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + PrimaryMode: PrimaryMode.Build, + SessionId: null); + + var exitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "approvals", ["reset"]), + context, + CancellationToken.None); + + exitCode.Should().Be(0); + replState.ApprovalSettingsOverride.Should().BeNull(); + renderer.LastCommandResult!.Message.Should().Contain("reset"); + } + private sealed class StubOutputRenderer : IOutputRenderer { public OutputFormat Format => OutputFormat.Text; diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs index 57eb969..da7719e 100644 --- a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/MemorySkillsGitServiceTests.cs @@ -118,12 +118,27 @@ public async Task GitWorkspaceService_should_list_create_and_prune_worktrees() var pruned = await service.PruneWorktreesAsync("/repo", CancellationToken.None); initial.Worktrees.Should().HaveCount(2); - created.Worktree.Path.Should().Be("/repo-new"); + created.Worktree.Path.Should().Be(Path.GetFullPath(Path.Combine("/repo", "../repo-new"))); created.Worktree.Branch.Should().Be("feature/new"); pruned.PrunedCount.Should().Be(1); pruned.Worktrees.Should().NotContain(entry => entry.IsPrunable); } + /// + /// Ensures linked worktrees resolve the main worktree path from git-common-dir output. + /// + [Fact] + public async Task GitWorkspaceService_should_detect_linked_worktree_main_path() + { + IGitWorkspaceService service = new GitWorkspaceService(new LinkedWorktreeProcessRunner()); + + var snapshot = await service.GetSnapshotAsync("/repo-linked", CancellationToken.None); + + snapshot.IsLinkedWorktree.Should().BeTrue(); + snapshot.MainWorktreePath.Should().Be("/repo"); + snapshot.WorktreeCount.Should().Be(2); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-memory-skill-git-tests", Guid.NewGuid().ToString("N")); @@ -203,4 +218,34 @@ private string RenderWorktreeList() private sealed record WorktreeState(string Branch, string Head, bool IsPrunable); } + + private sealed class LinkedWorktreeProcessRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) + { + var output = request.Arguments switch + { + ["rev-parse", "--show-toplevel"] => "/repo-linked\n", + ["rev-parse", "--path-format=absolute", "--git-common-dir"] => "/repo/.git/worktrees/repo-linked\n", + ["branch", "--show-current"] => "feature/worktrees\n", + ["rev-parse", "HEAD"] => "def456\n", + ["status", "--porcelain=v1", "--branch"] => "## feature/worktrees\n", + ["diff", "--no-ext-diff", "--stat"] => string.Empty, + ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"] => "0\t0\n", + ["worktree", "list", "--porcelain"] => """ + worktree /repo + HEAD abc123 + branch refs/heads/main + + worktree /repo-linked + HEAD def456 + branch refs/heads/feature/worktrees + + """, + _ => throw new InvalidOperationException($"Unexpected git command: {string.Join(' ', request.Arguments)}") + }; + + return Task.FromResult(new ProcessRunResult(0, output, string.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/InstructionRuleServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/InstructionRuleServiceTests.cs new file mode 100644 index 0000000..107344a --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/InstructionRuleServiceTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Runtime.Context; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies instruction rule loading respects document and total-size budgets. +/// +public sealed class InstructionRuleServiceTests +{ + [Fact] + public async Task LoadAsync_should_not_exceed_total_budget_when_remaining_space_is_tiny() + { + var workspacePath = CreateTemporaryWorkspace(); + var globalRulesPath = Path.Combine(workspacePath, ".sharpclaw-home", "rules"); + Directory.CreateDirectory(globalRulesPath); + + for (var index = 0; index < 11; index++) + { + await File.WriteAllTextAsync( + Path.Combine(globalRulesPath, $"rule-{index:D2}.md"), + new string((char)('a' + index), 1_090), + CancellationToken.None); + } + + await File.WriteAllTextAsync( + Path.Combine(globalRulesPath, "rule-11.md"), + new string('z', 200), + CancellationToken.None); + + var service = new InstructionRuleService( + new LocalFileSystem(), + new PathService(), + new FixedUserProfilePaths(workspacePath)); + + var snapshot = await service.LoadAsync(workspacePath, CancellationToken.None); + + snapshot.Documents.Should().HaveCount(12); + snapshot.Documents.Sum(static document => document.Content.Length).Should().BeLessThanOrEqualTo(12_000); + snapshot.Documents[^1].Content.Length.Should().BeLessThanOrEqualTo(10); + snapshot.Documents[^1].IsTruncated.Should().BeTrue(); + } + + private static string CreateTemporaryWorkspace() + { + var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-rule-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspacePath); + return workspacePath; + } + + private sealed class FixedUserProfilePaths(string root) : IUserProfilePaths + { + public string GetUserCustomCommandsDirectory() + => Path.Combine(root, ".sharpclaw-home", "commands"); + + public string GetUserHomeDirectory() + => root; + + public string GetUserSharpClawRoot() + => Path.Combine(root, ".sharpclaw-home"); + } +}