diff --git a/docs/reference/README.md b/docs/reference/README.md index 84570e1..53d651f 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -12,6 +12,7 @@ first-run guide. These pages are the authority for options, guarantees, and edge custom fields, and import. - [Claims and ownership](claims.md) — claimant attribution, the ownership rules, fencing guarantees per backend, recovery paths, and escape hatches. +- [Action discovery](actions.md) — current actions, blocked alternatives, stable names, and JSON. - [Operator actions by surface](operator-actions.md) — task-oriented comparison of what the Local web console, GitHub, and CLI can view or perform, with links to the authoritative procedures. - [Supported agents and surfaces](supported-agents.md) — which agent families Wrighty supports for diff --git a/docs/reference/actions.md b/docs/reference/actions.md new file mode 100644 index 0000000..d3ace88 --- /dev/null +++ b/docs/reference/actions.md @@ -0,0 +1,77 @@ +# Action discovery + +Use `wrighty actions` to inspect what you can do with an item before choosing an operation: + +```shell +wrighty actions local:42 +wrighty actions local:42 --all --json +wrighty actions local:42 open-item +``` + +The command supports Local Markdown and GitHub IDs, including the same short-ID resolution as +`get`. By default it shows available actions. `--all` includes blocked alternatives and their stable +reason codes. An optional action name selects one descriptor; an unknown name returns +`ACTION_UNKNOWN`, while a known unavailable selection returns its refusal code. These errors use +Wrighty's normal nonzero exit status and stderr JSON contract. Do not combine a selected name with +`--all`. + +Discovery is read-only. All descriptors currently report `execution: "manual-only"`, and `--exec` +returns `ACTION_EXECUTION_UNSUPPORTED`. Review the displayed guidance and use the existing focused +CLI command or web control when you have authorized the operation. Listing an action never claims +an item, starts a vendor session, grants permission, or overrides a pending retry. + +## Action vocabulary + +| Name | Meaning | +| --- | --- | +| `open-item` | Review the source issue or open the local web console. | +| `clarify` | Use the existing human-edit/takeover flow to clarify requirements. An agent must not infer takeover permission from this suggestion. | +| `answer-on-issue` | Add GitHub clarification using the configured context-approval or trusted-author workflow. | +| `clarify-and-continue` | Local Markdown guidance for editing requirements and then explicitly continuing that item. | +| `queue` | Move an untouched Local Markdown backlog item to the configured worker queue. | +| `send-back` | Return an untouched queued Local Markdown item to the inferred configured backlog. | +| `resume` | Queue an eligible retained Local Markdown session for a continuous worker. | +| `continue-worker` | Start targeted headless continuation, including a directed handoff where applicable. | +| `resume-session` | Open the recorded vendor session interactively on its recording installation. | +| `retry-now` | Explicitly override a scheduled retry timer. | +| `inspect-recovery` | Read the item and operational status for current recovery details. | + +Names are stable selectors; titles and descriptions are presentation. Queue, Send back, and Resume +currently describe the corresponding Board controls; they do not have generic CLI executors. +With worker-queue authorization enabled, Queue authorizes automatic processing and Send back revokes +that authorization. When it is disabled, execution policy remains independent. Resume queues the +recorded session and does not start a worker. These actions are not interchangeable status moves. + +A clarification pause may have a recommended action. Other states can legitimately have no +recommendation. A scheduled retry or handoff is deferred work, not an instruction to start another +process immediately. Recommendations never authorize execution. + +## JSON and state freshness + +`--json` returns `schemaVersion: 1` and `result` containing: + +- `itemId`, `stateObservedAt`, and nullable `recommendedAction`; +- `actions[]` with `name`, `title`, `description`, and `recommended`; +- `availability`, `unavailableCode`, and `unavailableReason`; +- `kind`, `execution`, `confirmation`, `requiresTty`, and `startsProcess`; and +- separate `commands`, `url`, and `agentPrompt` presentation fields. + +URLs are links, not shell commands. Command sequences and agent prompts remain inert guidance. +Never replay serialized descriptors as execution authority or parse a human title to choose an +action. The actual operation revalidates current claims, context, permissions, and runtime state. +A manual command can still fail if state changes after discovery or a later launch check refuses it. + +A missing, incomplete, remote, or unavailable workspace blocks local session actions. An active +claimant blocks competing session actions. Recorded worker continuation uses the existing +read-only targeted-worker preflight; no provider usage probe runs during discovery. Missing local +admission evidence is `ACTION_STATE_UNVERIFIED`, rather than permission to guess. Failed local +checks leave unrelated review actions visible. + +`get --json` exposes the catalogue under `result.actions`. Status group items expose it under their +`actions` field; both additions preserve existing fields. Human `get` shows concise next actions, +and human `status` points high-attention items to the full discovery command. Worker attention, +retry, and handoff guidance uses shared named factories. Existing handovers are snapshots; refresh +with `wrighty actions` before deciding what is currently available. + +See [worker lifecycle](worker.md), [claims and ownership](claims.md), and +[operator actions by surface](operator-actions.md) for the underlying procedures. diff --git a/docs/reference/operator-actions.md b/docs/reference/operator-actions.md index 3a1aa4a..7eb31d4 100644 --- a/docs/reference/operator-actions.md +++ b/docs/reference/operator-actions.md @@ -18,6 +18,9 @@ The three surfaces are: - **GitHub** — the issue, Project fields, labels, and Wrighty's single status comment. - **CLI** — works with both Local Markdown and GitHub. +For a live item-specific catalogue, use `wrighty actions ID` or `wrighty actions ID --all --json`. +See [Action discovery](actions.md) for its read-only contract and stable refusal reasons. + ## State and authority Before acting, distinguish policy, portable lifecycle state, local operational state, and diff --git a/docs/reference/worker.md b/docs/reference/worker.md index 45ddfe0..e30f657 100644 --- a/docs/reference/worker.md +++ b/docs/reference/worker.md @@ -1,5 +1,9 @@ # Autonomous worker mode +Use `wrighty actions ID` for current recovery alternatives and their availability. +[Action discovery](actions.md) refreshes the advice captured in a worker handover without +starting a worker or consuming a provider probe. + `wrighty worker` schedules one explicitly eligible item at a time, claims it with a fenced handle, starts Claude Code, Codex, Copilot, or OpenCode headlessly, renews the claim for a fixed budget, and records the workspace and vendor session address. Wrighty is the scheduler; the vendor CLI remains the diff --git a/docs/workflows.md b/docs/workflows.md index f7d4a8f..fc3721a 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -50,6 +50,9 @@ claim state, remaining lease, and any resumable session. A worker-originated act as ` processing`; this describes Wrighty's coordination state and is not a guarantee that the vendor process is making progress. Add `--json` for scripts. +Use `wrighty actions ID` to discover current next actions, or add `--all --json` to inspect blocked +alternatives. See [Action discovery](reference/actions.md); discovery never starts the operation. + ### Web console For Local Markdown, start the web console and select a card: diff --git a/src/Highbyte.Wrighty.Cli/CliActionCommands.cs b/src/Highbyte.Wrighty.Cli/CliActionCommands.cs new file mode 100644 index 0000000..ede3258 --- /dev/null +++ b/src/Highbyte.Wrighty.Cli/CliActionCommands.cs @@ -0,0 +1,127 @@ +using System.CommandLine; +using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Errors; +using Highbyte.Wrighty.Models; +using Highbyte.Wrighty.Processes; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.Cli; + +public sealed partial class CliApplication +{ + private Command BuildActionsCommand() + { + var id = WorkItemIdArgument(); + var name = new Argument("action-name") { Arity = ArgumentArity.ZeroOrOne }; + var all = new Option("--all") { Description = "Include unavailable actions and their reasons." }; + var json = JsonOption(); + var exec = new Option("--exec") { Description = "Reserved; action execution is not supported yet." }; + var command = new Command("actions", "Discover the actions available for a work item (read-only)"); + command.Arguments.Add(id); + command.Arguments.Add(name); + command.Options.Add(all); + command.Options.Add(json); + command.Options.Add(exec); + command.SetAction((parsed, cancellationToken) => ExecuteAsync(parsed.GetValue(json), async config => + { + if (parsed.GetValue(exec)) + throw new TrackerException("ACTION_EXECUTION_UNSUPPORTED", + "Action discovery is read-only; use the documented focused command after review.", 2); + var itemId = tracker.ResolveId(config, parsed.GetValue(id)!); + var state = await tracker.GetOperationalAsync(config, itemId, cancellationToken); + var discovery = await DiscoverActionsAsync(config, state, cancellationToken); + var shown = SelectActions(discovery, parsed.GetValue(name), parsed.GetValue(all)); + await writer.WriteActionsAsync(shown, parsed.GetValue(json)); + }, cancellationToken)); + return command; + } + + private static OperationalActionDiscovery SelectActions( + OperationalActionDiscovery discovery, string? selected, bool all) + { + if (selected is null) + return discovery with + { + Actions = discovery.Actions.Where(action => all || action.Availability == "available").ToArray() + }; + var action = discovery.Actions.SingleOrDefault(value => value.Name == selected) + ?? throw new TrackerException("ACTION_UNKNOWN", $"Unknown action '{selected}'.", 2); + if (all) + throw new TrackerException("ARGUMENT_INVALID", "--all cannot be combined with an action name.", 2); + if (action.UnavailableCode is { } code) + throw new TrackerException(code, action.UnavailableReason!, 5); + return discovery with { Actions = [action] }; + } + + private async Task DiscoverActionsAsync( + TrackerConfig config, WorkItemOperationalState state, CancellationToken cancellationToken) + { + var session = state.Session; + var context = new OperationalActionContext(config, state, + clock?.Invoke() ?? DateTimeOffset.UtcNow, + session is { FromCurrentInstallation: true, WorkspacePath: { } path } && Directory.Exists(path)); + if (OperationalActionResolver.SessionAvailability(context).Code is null) + { + context = context with + { + InteractiveAdmission = DescribeInteractiveAvailability(session!.Agent!), + WorkerAdmission = await DescribeWorkerAvailabilityAsync(config, state, cancellationToken) + }; + } + if (session?.Continuation is { } continuation) + { + var settings = config.Worker?.EffectiveContinuation ?? new WorkerContinuationConfig(); + context = context with + { + ContinuationBudget = continuation.BudgetWith( + settings.MaxAutomaticContinuations, settings.Cooldown, settings.Debounce) + }; + } + return OperationalActionResolver.Resolve(context); + } + private ActionAvailability DescribeInteractiveAvailability(string agent) + { + try + { + if (agents.Find(agent)?.InteractiveAdapter is null) + return new("AGENT_INTERACTIVE_UNSUPPORTED", "The recorded agent has no interactive resume adapter."); + var runtime = (runtimes ?? new AgentRuntimeCatalog(agents, new PathExecutableResolver())) + .Snapshot().Find(agent); + return runtime?.Installed == true ? ActionAvailability.Available + : new("AGENT_NOT_INSTALLED", "The recorded agent CLI is not installed."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or TrackerException) + { + return ActionAvailability.Unverified; + } + } + + private async Task DescribeWorkerAvailabilityAsync( + TrackerConfig config, WorkItemOperationalState state, CancellationToken cancellationToken) + { + if (workerService is null || state.Item.Archived || + string.Equals(state.Item.Status, config.DefaultFinishTo, StringComparison.OrdinalIgnoreCase)) + return ActionAvailability.Unverified; + try + { + // Reuse the worker's read-only exact-item admission path. Never claim work, prepare a + // workspace, start a vendor process, or probe paid provider capacity for discovery. + var options = new WorkerOptions(null, true, null, WorkspaceMode.Current, + new Dictionary(), null, TimeSpan.FromHours(1), + FencedAction.Kill, null, "agent", true, true); + await workerService.PreflightItemAsync(config, options, workingDirectory, state.Item.Id, + WorkerItemIntent.Auto, _ => Task.CompletedTask, cancellationToken); + return ActionAvailability.Available; + } + catch (TrackerException exception) + { + return new(exception.Code, exception.Message); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return ActionAvailability.Unverified; + } + } + +} diff --git a/src/Highbyte.Wrighty.Cli/CliApplication.cs b/src/Highbyte.Wrighty.Cli/CliApplication.cs index 58a3196..1f34db2 100644 --- a/src/Highbyte.Wrighty.Cli/CliApplication.cs +++ b/src/Highbyte.Wrighty.Cli/CliApplication.cs @@ -212,6 +212,7 @@ private RootCommand BuildRootCommand() root.Subcommands.Add(BuildListCommand()); root.Subcommands.Add(BuildStatusCommand()); root.Subcommands.Add(BuildGetCommand()); + root.Subcommands.Add(BuildActionsCommand()); root.Subcommands.Add(BuildContextCommand()); root.Subcommands.Add(BuildApproveCommand()); root.Subcommands.Add(BuildApprovalWorkflowCommand()); @@ -2948,7 +2949,8 @@ await writer.WriteOperationalDetailAsync( value => tracker.FormatShort(config, value), workspaceStatus, PendingInterruptions(config).Any(value => - string.Equals(value.ItemId, id.Value, StringComparison.Ordinal))); + string.Equals(value.ItemId, id.Value, StringComparison.Ordinal)), + await DiscoverActionsAsync(config, item, cancellationToken)); }, cancellationToken)); return command; @@ -2978,6 +2980,10 @@ private async Task StatusAsync(TrackerConfig config, bool json, CancellationToke var effectiveProviderCapacity = await EffectiveProviderCapacityAsync(config, cancellationToken); var configurationRevision = await StatusConfigurationRevisionAsync(config, cancellationToken); + var actions = new Dictionary(StringComparer.Ordinal); + foreach (var item in items) + actions[item.Item.Id.Value] = await DiscoverActionsAsync(config, item, cancellationToken); + await writer.WriteStatusAsync( items, workspaceStatuses, @@ -2992,7 +2998,8 @@ config.SourcePath is null ? [] : await workerInstances.ListAsync(config.SourcePath, cancellationToken), configurationRevision, - PendingInterruptions(config))); + PendingInterruptions(config), + actions)); } private async Task> StatusWorkspaceStatusesAsync( diff --git a/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs b/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs new file mode 100644 index 0000000..695a7bf --- /dev/null +++ b/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs @@ -0,0 +1,42 @@ +using Highbyte.Wrighty.Actions; + +namespace Highbyte.Wrighty.Cli.Output; + +public sealed partial class OutputWriter +{ + public async Task WriteActionsAsync(OperationalActionDiscovery discovery, bool json) + { + if (json) + { + await WriteJsonAsync(new { schemaVersion = 1, result = discovery }); + return; + } + await output.WriteLineAsync($"Actions for {discovery.ItemId}"); + await output.WriteLineAsync("Discovery only; no action is executed."); + if (discovery.Actions.Count == 0) + await output.WriteLineAsync("No available actions."); + foreach (var action in discovery.Actions) + await WriteActionAsync(action); + } + + private async Task WriteActionAsync(OperationalAction action) + { + await output.WriteLineAsync(); + var recommended = action.Recommended ? " (recommended)" : string.Empty; + await output.WriteLineAsync($"{action.Name} — {action.Title}{recommended}"); + await output.WriteLineAsync($" {action.Description}"); + await output.WriteLineAsync($" {action.Availability}; execution: {action.Execution}; confirmation: {action.Confirmation}"); + if (action.UnavailableCode is { } code) + await output.WriteLineAsync($" {code}: {action.UnavailableReason}"); + if (action.RequiresTty) + await output.WriteLineAsync(" Requires an interactive terminal for the displayed interactive command."); + if (action.StartsProcess) + await output.WriteLineAsync(" Starts a process when explicitly invoked."); + if (action.Url is { } url) + await output.WriteLineAsync($" Link: {url}"); + foreach (var command in action.Commands) + await output.WriteLineAsync($" {command}"); + if (action.AgentPrompt is { } prompt) + await output.WriteLineAsync($" Prompt for the agent session: {prompt}"); + } +} diff --git a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs index 87d2996..a148385 100644 --- a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs +++ b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs @@ -1,3 +1,4 @@ +using Highbyte.Wrighty.Actions; using System.Text.Json; using System.Text.Json.Serialization; using Highbyte.Wrighty.AgentContext; @@ -20,9 +21,10 @@ public sealed record StatusOutputContext( IReadOnlyList? ProviderCapacities = null, IReadOnlyList? WorkerInstances = null, string? ConfigurationRevision = null, - IReadOnlyList? PendingInterruptions = null); + IReadOnlyList? PendingInterruptions = null, + IReadOnlyDictionary? Actions = null); -public sealed class OutputWriter( +public sealed partial class OutputWriter( TextWriter output, TextWriter error, Func? clock = null) @@ -174,19 +176,19 @@ await WriteJsonAsync(new result = new { needsAttention = needsAttention - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), completed = completed - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), paused = paused - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), active = active - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), queued = queued - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), retries = retries - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), handoffs = handoffs - .Select(value => StatusDto(value, workspaceStatuses, formatShort)).ToArray(), + .Select(value => StatusDto(value, workspaceStatuses, formatShort, context?.Actions?.GetValueOrDefault(value.Item.Id.Value))).ToArray(), providerCapacity, localWorkers, pendingInterruptions, @@ -219,10 +221,7 @@ await WriteStatusGroupAsync("Needs attention", needsAttention, formatShort, async value => { await WriteLastRunExcerptAsync(value); - await output.WriteLineAsync( - $" wrighty edit {value.Item.Id.Value} --takeover --yes --body-file requirements.md --requeue"); - await output.WriteLineAsync( - $" wrighty worker --item {value.Item.Id.Value} --yes"); + await WriteActionSummaryAsync(value, context); }); await WriteStatusGroupAsync("Completed — retained worktree", completed, formatShort, value => WriteWorktreeAndCompletionAsync(value, workspaceStatuses, integration)); @@ -230,10 +229,7 @@ await WriteStatusGroupAsync("Paused — resumable session", paused, formatShort, async value => { await WriteLastRunExcerptAsync(value); - await output.WriteLineAsync( - $" wrighty resume-command {value.Item.Id.Value}"); - await output.WriteLineAsync( - $" wrighty worker --item {value.Item.Id.Value} --yes"); + await WriteActionSummaryAsync(value, context); }); await WriteStatusGroupAsync("Active", active, formatShort, value => @@ -413,7 +409,8 @@ private async Task WriteCompletionGuidanceAsync( private object StatusDto( WorkItemOperationalState value, IReadOnlyDictionary workspaceStatuses, - Func formatShort) + Func formatShort, + OperationalActionDiscovery? actionDiscovery = null) { var status = workspaceStatuses.GetValueOrDefault(value.Item.Id.Value); return new @@ -423,6 +420,7 @@ private object StatusDto( value.Item.Title, value.Item.Status, operationalStatus = value.OperationalStatus, + actions = actionDiscovery, branch = value.Session?.Branch, hasRecordedWorktree = value.Session?.HasRecordedWorktree ?? false, lastRun = value.Session?.Outcome is not { } outcome @@ -464,7 +462,8 @@ public async Task WriteOperationalDetailAsync( bool json, Func formatShort, WorkspaceStatusResult? workspaceStatus = null, - bool interruptionBookkeepingIncomplete = false) + bool interruptionBookkeepingIncomplete = false, + OperationalActionDiscovery? actionDiscovery = null) { if (json) { @@ -476,7 +475,8 @@ await WriteJsonAsync(new formatShort, includeBody: true, workspaceStatus, - interruptionBookkeepingIncomplete) + interruptionBookkeepingIncomplete, + actionDiscovery) }); return; } @@ -497,7 +497,7 @@ await output.WriteLineAsync( foreach (var field in item.EffectiveFields.OrderBy(pair => pair.Key, StringComparer.Ordinal)) await output.WriteLineAsync($"{field.Key}: {field.Value}"); - await WriteOperationalActionsAsync(value); + await WriteOperationalActionsAsync(actionDiscovery); await output.WriteLineAsync(); await output.WriteLineAsync("Body"); await output.WriteAsync(item.Body); @@ -851,15 +851,29 @@ await output.WriteLineAsync( } } - private async Task WriteOperationalActionsAsync(WorkItemOperationalState value) + private async Task WriteOperationalActionsAsync(OperationalActionDiscovery? discovery) { - var actions = OperationalActions(value); - if (actions.Count == 0) + if (discovery is null) return; await output.WriteLineAsync(); await output.WriteLineAsync("Next actions"); - foreach (var action in actions) - await output.WriteLineAsync($" {action}"); + foreach (var action in discovery.Actions.Where(action => action.Availability == "available")) + { + await output.WriteLineAsync($" {action.Name}: {action.Title}"); + if (action.Url is { } url) + await output.WriteLineAsync($" Link: {url}"); + else if (action.Commands.Count > 0) + await output.WriteLineAsync($" {action.Commands[0]}"); + } + await output.WriteLineAsync($" wrighty actions {discovery.ItemId} --all"); + } + + private async Task WriteActionSummaryAsync(WorkItemOperationalState value, StatusOutputContext? context) + { + var discovery = context?.Actions?.GetValueOrDefault(value.Item.Id.Value); + if (discovery?.RecommendedAction is { } recommended) + await output.WriteLineAsync($" Recommended: {recommended}"); + await output.WriteLineAsync($" wrighty actions {value.Item.Id.Value}"); } public async Task WriteInitializationAsync( @@ -1834,34 +1848,13 @@ private static string Truncate(string value, int width) => ? value[..width] : $"{value[..(width - 1)]}…"; - private static IReadOnlyList OperationalActions( - WorkItemOperationalState value) - { - if (value.OperationalStatus is not ( - OperationalStatuses.NeedsAttention or - OperationalStatuses.Queued or - OperationalStatuses.RetryScheduled or - OperationalStatuses.HandoffQueued or - OperationalStatuses.PausedSession)) - return []; - // The web console's board is Local Markdown only; GitHub items carry a URL, so point there instead. - var reviewAction = value.Item.Url is { } issueUrl - ? $"Review on GitHub: {issueUrl}" - : "Open web UI: wrighty web"; - return - [ - reviewAction, - $"Edit requirements: wrighty edit {value.Item.Id.Value} --takeover", - $"Resume headlessly: wrighty worker --item {value.Item.Id.Value} --yes" - ]; - } - private object OperationalDto( WorkItemOperationalState value, Func formatShort, bool includeBody = false, WorkspaceStatusResult? workspaceStatus = null, - bool interruptionBookkeepingIncomplete = false) + bool interruptionBookkeepingIncomplete = false, + OperationalActionDiscovery? actionDiscovery = null) { // A single nullable view collapses the repeated "unclaimed ? null : …" projections into // null-conditional access below (which does not add to cognitive complexity). @@ -1869,6 +1862,7 @@ private object OperationalDto( return new { id = value.Item.Id.Value, + actions = actionDiscovery, displayId = formatShort(value.Item.Id), value.Item.Title, body = includeBody ? value.Item.Body : null, diff --git a/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs b/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs new file mode 100644 index 0000000..113b33e --- /dev/null +++ b/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs @@ -0,0 +1,51 @@ +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.Actions; + +/// A snapshot of advice, never a claim or executable authority. +public sealed record OperationalAction( + string Name, + string Title, + string Description, + string Availability, + string? UnavailableCode, + string? UnavailableReason, + string Kind, + string Confirmation, + bool RequiresTty, + bool StartsProcess, + IReadOnlyList Commands, + string? Url = null, + string? AgentPrompt = null, + bool Recommended = false) +{ + // The foundation deliberately has no executors, including for otherwise available actions. + public string Execution { get; } = "manual-only"; + + public static OperationalAction FromGuidance( + WorkerOperatorAction guidance, + ActionAvailability availability, + string kind = "wrighty-operation", + string confirmation = "none", + bool requiresTty = false, + bool startsProcess = false) => new( + guidance.Name ?? throw new ArgumentException("Action guidance needs a stable name."), + guidance.Scenario, guidance.Description, + availability.Code is null ? "available" : "unavailable", + availability.Code, availability.Reason, + kind, confirmation, requiresTty, startsProcess, + guidance.Commands, guidance.Url, guidance.AgentPrompt); +} + +public sealed record ActionAvailability(string? Code = null, string? Reason = null) +{ + public static ActionAvailability Available { get; } = new(); + public static ActionAvailability Unverified { get; } = new( + "ACTION_STATE_UNVERIFIED", "The required local state could not be verified."); +} + +public sealed record OperationalActionDiscovery( + string ItemId, + DateTimeOffset StateObservedAt, + string? RecommendedAction, + IReadOnlyList Actions); diff --git a/src/Highbyte.Wrighty.Core/Actions/OperationalActionGuidance.cs b/src/Highbyte.Wrighty.Core/Actions/OperationalActionGuidance.cs new file mode 100644 index 0000000..19138e6 --- /dev/null +++ b/src/Highbyte.Wrighty.Core/Actions/OperationalActionGuidance.cs @@ -0,0 +1,167 @@ +using Highbyte.Wrighty.Models; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.Actions; + +/// Shared, inert guidance used by CLI discovery and worker handover snapshots. +public static class OperationalActionGuidance +{ + public static IReadOnlyList NeedsAttentionActions( + WorkItemId id, + string agentName, + OperatorSurface surface, + DateTimeOffset? activeUntil = null, + ApprovedContext.TrustedContinuationBudget? budget = null) + { + var agentLabel = agentName.Length == 0 + ? "agent" + : $"{char.ToUpperInvariant(agentName[0])}{agentName[1..]}"; + var actions = new List(); + // The promise "a reply alone continues this" is only true while the session has automatic + // continuations left. Once the budget is spent, an item stays here until an operator acts — + // by design — and guidance that keeps promising hands-off continuation makes that design + // read as a defect: the operator replies, nothing happens, nothing says why. So exhaustion + // switches the text, and names the limit as the reason. + var continuesAutomatically = + surface.ContinuesOnTrustedReply && budget is not { IsExhausted: true }; + + if (surface is { Kind: OperatorSurfaceKind.GitHubIssue, ItemUrl: { } url }) + { + // Two steps because both are required and the second is the one everybody forgets: + // approval is an instant, so re-selecting the value the field already holds moves + // nothing and the new comment stays undecided. The walkthrough exists partly to make + // that failure visible, which is a sign it needs saying here. + // The URL stays even though this is rendered onto the issue itself: the same action + // list is printed in the worker's terminal, where it is the only pointer to the item. + // Hence "on the issue" rather than "here", which is only true in one of the two places. + // Naming a trusted author changes the answer to "what do I do now?" enough that the two + // cases are written out separately rather than hedged into one. Where a reply is enough + // on its own, saying so matters: an operator who has been told to toggle a field will + // keep toggling it, and conclude Wrighty is broken when nothing needed to happen. + if (surface.ContinuesOnTrustedReply && !continuesAutomatically) + actions.Add(new WorkerOperatorAction( + "Answer on the issue, then queue it yourself — automatic continuation is spent", + [url], + $"This session has used all {budget!.MaxAutomaticContinuations} automatic " + + "continuations, so a reply alone no longer starts it. That is the configured " + + "limit holding, not a fault; the item stays here until you act. Reply in a " + + "new comment with the clarification — a trusted author's reply still needs no " + + "approval change; anyone else's needs " + + $"\"{surface.ContextApprovalField}\" set to any other value and back to " + + $"\"{surface.ApprovedOption}\", both moves — then start the session with the " + + "command below, or set " + + $"\"{surface.DispatchStateField}\" to \"{DispatchStates.Queued}\" for the " + + "next continuous worker on the recording host.\n\n" + + "Do not edit the description: that replaces what this paused session was " + + "already given, and only a run you name yourself can proceed across such a " + + "change.", Name: "answer-on-issue", Url: url)); + else + actions.Add(continuesAutomatically + ? new WorkerOperatorAction( + "Answer on the issue — nothing else needed", + [url], + "Reply in a new comment on this issue with the clarification. If you are one " + + "of the configured trusted authors, a continuous worker picks the item up and " + + "continues this same session with what you wrote — no approval change and no " + + "command. Give it a moment: replies are left to settle briefly so an edit " + + "straight after posting is the version the agent reads.\n\n" + + "Do not edit the description: that replaces what this paused session was " + + "already given, and only a run you name yourself can proceed across such a " + + "change.\n\n" + + "If you are not a trusted author, your reply still needs a decision — set " + + $"\"{surface.ContextApprovalField}\" to any other value and back to " + + $"\"{surface.ApprovedOption}\", both moves, since approval is an instant and " + + "re-selecting the value it already holds moves nothing.", Name: "answer-on-issue", Url: url) + : new WorkerOperatorAction( + "Answer on the issue — no CLI needed", + [url], + "1. Reply in a new comment on this issue with the clarification. Do not edit " + + "the description: that replaces what this paused session was already given, " + + "and only a run you name yourself can proceed across such a change.\n" + + $"2. Set \"{surface.ContextApprovalField}\" to any other value and back to " + + $"\"{surface.ApprovedOption}\" — both moves. Approval is an instant, so " + + "re-selecting the value it already holds moves nothing and your reply stays " + + "undecided.\n\n" + + "Your reply then reaches the agent as an addition to what it already holds, " + + "which any worker may carry to it.", Name: "answer-on-issue", Url: url)); + actions.Add(new WorkerOperatorAction( + continuesAutomatically + ? $"Or start {agentLabel} yourself" + : $"Then start {agentLabel} again", + [$"wrighty worker --item {id.Value} --yes"], + $"Runs it now, reusing the recorded session. To keep it hands-off instead, set " + + $"\"{surface.DispatchStateField}\" to \"{DispatchStates.Queued}\" and leave it: a " + + "continuous worker takes the item once this claim lapses, and only on the host " + + "that recorded the session.", Name: "continue-worker")); + } + else + { + actions.Add(new WorkerOperatorAction( + "Edit the requirements in the web UI", + ["wrighty web"], + $"Open {id.Value}, then take over (or claim after expiry) and edit it. Choose Save " + + $"and resume automatically to let a continuous worker continue it, Save and show " + + $"manual {agentLabel} resume command under More actions to continue it yourself, " + + "Finish when complete, or Archive to close it without more agent work.", Name: "open-item")); + // Not --requeue. Rewriting the description supersedes the approved context the paused + // session already holds, and a continuous worker refuses to resume a session across a + // change nobody named the item to approve — so pairing the two queues a run that is + // certain to be refused. Naming the item is what carries that judgement, so the + // clarification and the run are two commands here rather than one. This backend has no + // discussion to append to, so rewriting is the only way to clarify it. + actions.Add(new WorkerOperatorAction( + "Clarify the requirements, then continue the session yourself", + [ + $"wrighty edit {id.Value} --takeover --yes --body-file requirements.md", + $"wrighty worker --item {id.Value} --yes" + ], + "The first saves the clarification and ends human ownership. The second resumes " + + "the recorded session: because you named the item, Wrighty proceeds despite the " + + "changed description and reports that it did.", Name: "clarify-and-continue")); + } + + var ownershipDescription = activeUntil is null + ? "There is no active claimant to displace, so Wrighty acquires a human editing claim." + : $"The current claim is active until {activeUntil:O}. edit --takeover works before or " + + "after that time: while active, Wrighty asks you to confirm displacing the current " + + "claimant; after expiry, it acquires a human editing claim without prompting. The " + + "recorded local agent session is preserved in either case."; + var editWarning = surface.HasDiscussion + ? " Editing the description this way replaces what the session already holds, so only " + + "a run you name for this item will proceed across it — prefer a comment above." + : string.Empty; + actions.Add(new WorkerOperatorAction( + "Take the item over for editing", + [ + $"wrighty edit {id.Value} --takeover", + $"wrighty edit {id.Value} --takeover --yes --title \"Clear title\" " + + "--body-file requirements.md" + ], + $"{ownershipDescription} The first command opens the title and body in VISUAL or " + + "EDITOR. The second is the non-interactive form. Both retain the claim handle inside " + + $"Wrighty.{editWarning}", Name: "clarify")); + return actions; + } + + public static WorkerOperatorAction ContinueWorker(WorkItemId id) => new( + "Continue with a worker", [$"wrighty worker --item {id.Value} --yes"], + "Process this item headlessly using the recorded continuation or directed handoff. " + + "The worker revalidates claims, context, agent availability, and permissions before launch.", + Name: "continue-worker"); + + public static WorkerOperatorAction RetryNow(WorkItemId id) => new( + "Retry now", [$"wrighty worker --item {id.Value} --yes"], + "Explicitly override the timer and continue the recorded work now. This may consume provider usage.", + Name: "retry-now"); + + public static WorkerOperatorAction HandoffNow(WorkItemId id) => new( + "Run the handoff now", [$"wrighty worker --item {id.Value} --yes"], + "Start the directed agent as a new session in the retained workspace on the recording installation.", + Name: "continue-worker"); + + public static WorkerOperatorAction InspectRecovery(WorkItemId id) => new( + "Inspect local recovery state", [$"wrighty get {id.Value}", "wrighty status"], + "Read the current item, last run, dispatch decision, and local worker state.", + Name: "inspect-recovery"); + +} diff --git a/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs b/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs new file mode 100644 index 0000000..bda2eb6 --- /dev/null +++ b/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs @@ -0,0 +1,207 @@ +using Highbyte.Wrighty.Claims; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Models; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.Actions; + +public sealed record OperationalActionContext( + TrackerConfig Config, + WorkItemOperationalState State, + DateTimeOffset ObservedAt, + bool WorkspaceExists, + ActionAvailability? WorkerAdmission = null, + ActionAvailability? InteractiveAdmission = null, + ApprovedContext.TrustedContinuationBudget? ContinuationBudget = null); + +/// +/// Pure action policy over an observed snapshot. Launch checks are supplied by the owning runtime; +/// missing evidence fails closed for that action, without suppressing unrelated review guidance. +/// +public static class OperationalActionResolver +{ + private const string ConfirmationRequired = "required"; + + public static OperationalActionDiscovery Resolve(OperationalActionContext context) + { + var (config, state) = (context.Config, context.State); + var id = state.Item.Id; + var surface = OperatorSurface.For(config, state.Item.Url); + var guidance = OperationalActionGuidance.NeedsAttentionActions( + id, state.Session?.Agent ?? "", surface, + state.Claim.State == ClaimOwnershipState.Unclaimed ? null : state.Claim.ExpiresAt, + context.ContinuationBudget); + var review = surface.ItemUrl is { } url + ? new WorkerOperatorAction("Review item", [], "Review the item in its source tracker.", + Name: "open-item", Url: url) + : new WorkerOperatorAction("Open item in the web console", ["wrighty web"], + "Open the local web console and select this item.", Name: "open-item"); + List actions = + [ + OperationalAction.FromGuidance(review, ActionAvailability.Available, + surface.HasDiscussion ? "url" : "local-process", startsProcess: !surface.HasDiscussion) + ]; + AddClarificationActions(context, surface, guidance, actions); + AddBoardActions(context, actions); + var workerAvailability = WorkerAvailability(context); + if (state.OperationalStatus != OperationalStatuses.RetryScheduled) + actions.Add(OperationalAction.FromGuidance( + state.OperationalStatus == OperationalStatuses.HandoffQueued + ? OperationalActionGuidance.HandoffNow(id) : OperationalActionGuidance.ContinueWorker(id), + workerAvailability, confirmation: ConfirmationRequired, startsProcess: true)); + actions.Add(OperationalAction.FromGuidance( + new WorkerOperatorAction("Open recorded session", [$"wrighty resume-command {id.Value} --exec"], + "Open the recorded vendor session interactively on this installation.", Name: "resume-session"), + FirstBlocked(SessionAvailability(context), context.InteractiveAdmission ?? ActionAvailability.Unverified), + "local-process", ConfirmationRequired, requiresTty: true, startsProcess: true)); + actions.Add(OperationalAction.FromGuidance( + OperationalActionGuidance.RetryNow(id), + state.OperationalStatus == OperationalStatuses.RetryScheduled + ? workerAvailability : Block("RETRY_NOT_SCHEDULED", "No retry is scheduled for this item."), + confirmation: ConfirmationRequired, startsProcess: true)); + actions.Add(OperationalAction.FromGuidance(OperationalActionGuidance.InspectRecovery(id), + ActionAvailability.Available)); + // Only a clarification pause determines a next action without choosing an operator policy. + // Scheduled retries/handoffs remain deferred, never recommendations to override a timer. + var recommended = state.OperationalStatus == OperationalStatuses.NeedsAttention + ? actions.FirstOrDefault(value => value.Availability == "available" && + value.Name == (surface.HasDiscussion ? "answer-on-issue" : "clarify"))?.Name + : null; + return new(id.Value, context.ObservedAt, recommended, + actions.Select(value => value with { Recommended = value.Name == recommended }) + .OrderByDescending(value => value.Recommended).ToArray()); + } + + private static void AddClarificationActions( + OperationalActionContext context, OperatorSurface surface, + IReadOnlyList guidance, List actions) + { + var state = context.State; + var editable = EditAvailability(state); + var clarify = OperationalAction.FromGuidance( + guidance.Single(value => value.Name == "clarify"), editable, + confirmation: state.Claim.State == ClaimOwnershipState.Unclaimed ? "none" : ConfirmationRequired, + requiresTty: true); + actions.Add(clarify); + if (state.OperationalStatus == OperationalStatuses.NeedsAttention) + { + if (surface.HasDiscussion) + { + var answer = guidance.Single(value => value.Name == "answer-on-issue"); + actions.Add(OperationalAction.FromGuidance(answer with { Commands = [] }, + state.Item.Archived ? Block("ITEM_ARCHIVED", "The item is archived.") : ActionAvailability.Available, + "url", ConfirmationRequired)); + } + else + { + actions.Add(OperationalAction.FromGuidance( + guidance.Single(value => value.Name == "clarify-and-continue"), + FirstBlocked(editable, WorkerAvailability(context)), + "manual-steps", ConfirmationRequired, startsProcess: true)); + } + } + } + + private static void AddBoardActions(OperationalActionContext context, List actions) + { + var (config, state) = (context.Config, context.State); + var common = BoardAvailability(config, state); + var untouched = FirstBlocked(common, UntouchedAvailability(state)); + var queue = FirstBlocked(untouched, + Matches(state.Item.Status, config.DefaultPickFrom) || + Matches(state.Item.Status, config.DefaultPickTo) || Matches(state.Item.Status, config.DefaultFinishTo) + ? Block("WORKFLOW_STATE_INVALID", "Only an untouched backlog item can be queued.") + : ActionAvailability.Available); + var backlog = WorkflowStatusPolicy.InferBacklogStatus(config, config.LocalMarkdown?.Statuses ?? []); + var sendBack = FirstBlocked(untouched, SendBackAvailability(config, state, backlog)); + actions.Add(BoardAction("queue", "Queue", "Move to the configured worker queue. " + + QueueConsequence(config, true), queue)); + actions.Add(BoardAction("send-back", "Send back", $"Move back to {backlog ?? "the configured backlog"}. " + + QueueConsequence(config, false), sendBack)); + var resume = FirstBlocked(common, FirstBlocked(SessionAvailability(context), ResumeQueueAvailability(config, state))); + actions.Add(BoardAction("resume", "Resume", "Queue the recorded session for a continuous worker. " + + "This does not start a worker or change the requirements.", resume)); + } + + private static ActionAvailability BoardAvailability(TrackerConfig config, WorkItemOperationalState state) + { + if (!string.Equals(config.Backend, "local-markdown", StringComparison.OrdinalIgnoreCase)) + return Block("NOT_SUPPORTED", "This board operation is available for Local Markdown."); + return state.Item.Archived ? Block("ITEM_ARCHIVED", "The item is archived.") : ActionAvailability.Available; + } + + private static ActionAvailability UntouchedAvailability(WorkItemOperationalState state) + { + if (state.Claim.State != ClaimOwnershipState.Unclaimed) + return Block("CLAIM_HELD", "Release the existing claim before moving this item."); + return state.Item.DispatchState is not null + ? Block("WORKER_RECOVERY_PENDING", "The item has dispatch/recovery state.") + : ActionAvailability.Available; + } + + private static ActionAvailability SendBackAvailability( + TrackerConfig config, WorkItemOperationalState state, string? backlog) + { + if (!Matches(state.Item.Status, config.DefaultPickFrom)) + return Block("WORKFLOW_STATE_INVALID", "The item is not in the worker queue."); + return backlog is null ? Block("STATUS_UNAVAILABLE", "No backlog status is configured.") : ActionAvailability.Available; + } + + private static ActionAvailability ResumeQueueAvailability(TrackerConfig config, WorkItemOperationalState state) + { + if (state.OperationalStatus != OperationalStatuses.NeedsAttention) + return Block("WORKER_ITEM_NOT_PAUSED", "The item is not waiting for attention."); + return !state.Item.AutomaticExecutionAllowed || !Matches(state.Item.Status, config.DefaultPickTo) + ? Block("WORKER_ITEM_INELIGIBLE", "Resuming through the queue requires automatic execution and the active-work status.") + : ActionAvailability.Available; + } + + private static string QueueConsequence(TrackerConfig config, bool queue) + { + if (!config.EffectiveWorker.UseWorkerQueue) + return "Worker-queue authorization is disabled; execution policy remains independent."; + return queue ? "This authorizes automatic processing; it does not start a worker." + : "This revokes worker-queue authorization."; + } + + private static OperationalAction BoardAction(string name, string title, string description, + ActionAvailability availability) => OperationalAction.FromGuidance( + new WorkerOperatorAction(title, [], description + " Use the corresponding Board action in wrighty web.", + Name: name), availability, confirmation: ConfirmationRequired); + + private static ActionAvailability EditAvailability(WorkItemOperationalState state) + { + if (state.Item.Archived) + return Block("ITEM_ARCHIVED", "The item is archived."); + return state.Claim.State != ClaimOwnershipState.Unclaimed && !state.Claim.TakeoverAvailable + ? Block("CLAIM_HELD", "The current claim cannot be taken over here.") + : ActionAvailability.Available; + } + + public static ActionAvailability SessionAvailability(OperationalActionContext context) + { + var state = context.State; + if (state.Session is not { IsComplete: true } session) + return Block("RESUME_ADDRESS_UNAVAILABLE", "There is no complete recorded session."); + if (!session.FromCurrentInstallation) + return Block("RESUME_ADDRESS_NOT_LOCAL", "The session belongs to another installation."); + if (state.Claim.State == ClaimOwnershipState.HeldByOther) + return Block("CLAIM_NOT_OWNER", "Another installation owns the claim."); + if (state.OperationalStatus is OperationalStatuses.AgentActive or OperationalStatuses.WorkerPreparing + or OperationalStatuses.AutomationActive or OperationalStatuses.HumanEditing) + return Block("CLAIM_HELD", "An active claimant is using this item."); + return context.WorkspaceExists ? ActionAvailability.Available + : Block("RESUME_WORKTREE_ABSENT", "The recorded workspace is unavailable here."); + } + + private static ActionAvailability WorkerAvailability(OperationalActionContext context) => + context.State.Item.Archived || Matches(context.State.Item.Status, context.Config.DefaultFinishTo) + ? Block("WORKER_ITEM_TERMINAL", "Completed or archived work cannot be queued for implementation.") + : FirstBlocked(SessionAvailability(context), context.WorkerAdmission ?? ActionAvailability.Unverified); + + private static ActionAvailability FirstBlocked(ActionAvailability first, ActionAvailability second) => + first.Code is null ? second : first; + private static ActionAvailability Block(string code, string reason) => new(code, reason); + private static bool Matches(string? left, string? right) => + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Highbyte.Wrighty.Core/Workers/Handover.cs b/src/Highbyte.Wrighty.Core/Workers/Handover.cs index 049e3fc..8fbdbf5 100644 --- a/src/Highbyte.Wrighty.Core/Workers/Handover.cs +++ b/src/Highbyte.Wrighty.Core/Workers/Handover.cs @@ -366,11 +366,17 @@ private static void AppendAction(StringBuilder builder, WorkerOperatorAction act builder.AppendLine($" {line}"); } - if (action.Commands.Count > 0) + if (action.Url is { } url) + { + builder.AppendLine(); + builder.AppendLine($" Link: {url}"); + } + var commands = action.Commands.Where(command => command != action.Url).ToArray(); + if (commands.Length > 0) { builder.AppendLine(); builder.AppendLine(" ```"); - foreach (var command in action.Commands) + foreach (var command in commands) builder.AppendLine($" {command}"); builder.AppendLine(" ```"); } diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs index d9781a1..afa5126 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs @@ -71,7 +71,9 @@ public sealed record WorkerOperatorAction( // A second, distinct snippet that is pasted into the opened agent session (not run in the // terminal). Rendered as its own code block after Commands so the two destinations are not // conflated, and so a work-item id inside it is never auto-linked as prose. - string? AgentPrompt = null); + string? AgentPrompt = null, + string? Name = null, + string? Url = null); public sealed record WorkerEvent( string Type, diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs index 11a5fb1..235e2eb 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs @@ -4748,16 +4748,9 @@ await PostHandoverAsync( [$"wrighty provider probe {agentName}"], "Run this on the recording installation to perform one bounded provider " + "capacity check without claiming or changing the item. It may consume " + - "subscription usage and asks for confirmation."), - new WorkerOperatorAction( - "Retry now", - [$"wrighty worker --item {detail.Id.Value} --yes"], - "Explicitly override the timer and resume the recorded vendor session now."), - new WorkerOperatorAction( - "Inspect local recovery state", - [$"wrighty get {detail.Id.Value}", "wrighty status"], - "Run these on the recording installation for the exact timer and retained " + - "session details.") + "subscription usage and asks for confirmation.", Name: "probe-provider"), + Actions.OperationalActionGuidance.RetryNow(detail.Id), + Actions.OperationalActionGuidance.InspectRecovery(detail.Id) ], cancellationToken, projection, @@ -4972,17 +4965,8 @@ await PostHandoverAsync( schedule.Result, schedule.Workspace, [ - new WorkerOperatorAction( - "Run the handoff now", - [$"wrighty worker --item {detail.Id.Value} --yes"], - $"Run this on the recording installation to start the " + - $"{AgentDisplayName(schedule.TargetAgent)} session for the retained " + - "workspace without waiting for the next poll."), - new WorkerOperatorAction( - "Inspect local recovery state", - [$"wrighty get {detail.Id.Value}", "wrighty status"], - "Run these on the recording installation for the exact handoff details and " + - "the retained session and workspace paths.") + Actions.OperationalActionGuidance.HandoffNow(detail.Id), + Actions.OperationalActionGuidance.InspectRecovery(detail.Id) ], cancellationToken, projection, @@ -5130,137 +5114,8 @@ internal static IReadOnlyList NeedsAttentionActions( string agentName, OperatorSurface surface, DateTimeOffset? activeUntil = null, - ApprovedContext.TrustedContinuationBudget? budget = null) - { - var agentLabel = agentName.Length == 0 - ? "agent" - : $"{char.ToUpperInvariant(agentName[0])}{agentName[1..]}"; - var actions = new List(); - // The promise "a reply alone continues this" is only true while the session has automatic - // continuations left. Once the budget is spent, an item stays here until an operator acts — - // by design — and guidance that keeps promising hands-off continuation makes that design - // read as a defect: the operator replies, nothing happens, nothing says why. So exhaustion - // switches the text, and names the limit as the reason. - var continuesAutomatically = - surface.ContinuesOnTrustedReply && budget is not { IsExhausted: true }; - - if (surface is { Kind: OperatorSurfaceKind.GitHubIssue, ItemUrl: { } url }) - { - // Two steps because both are required and the second is the one everybody forgets: - // approval is an instant, so re-selecting the value the field already holds moves - // nothing and the new comment stays undecided. The walkthrough exists partly to make - // that failure visible, which is a sign it needs saying here. - // The URL stays even though this is rendered onto the issue itself: the same action - // list is printed in the worker's terminal, where it is the only pointer to the item. - // Hence "on the issue" rather than "here", which is only true in one of the two places. - // Naming a trusted author changes the answer to "what do I do now?" enough that the two - // cases are written out separately rather than hedged into one. Where a reply is enough - // on its own, saying so matters: an operator who has been told to toggle a field will - // keep toggling it, and conclude Wrighty is broken when nothing needed to happen. - if (surface.ContinuesOnTrustedReply && !continuesAutomatically) - actions.Add(new WorkerOperatorAction( - "Answer on the issue, then queue it yourself — automatic continuation is spent", - [url], - $"This session has used all {budget!.MaxAutomaticContinuations} automatic " + - "continuations, so a reply alone no longer starts it. That is the configured " + - "limit holding, not a fault; the item stays here until you act. Reply in a " + - "new comment with the clarification — a trusted author's reply still needs no " + - "approval change; anyone else's needs " + - $"\"{surface.ContextApprovalField}\" set to any other value and back to " + - $"\"{surface.ApprovedOption}\", both moves — then start the session with the " + - "command below, or set " + - $"\"{surface.DispatchStateField}\" to \"{DispatchStates.Queued}\" for the " + - "next continuous worker on the recording host.\n\n" + - "Do not edit the description: that replaces what this paused session was " + - "already given, and only a run you name yourself can proceed across such a " + - "change.")); - else - actions.Add(continuesAutomatically - ? new WorkerOperatorAction( - "Answer on the issue — nothing else needed", - [url], - "Reply in a new comment on this issue with the clarification. If you are one " + - "of the configured trusted authors, a continuous worker picks the item up and " + - "continues this same session with what you wrote — no approval change and no " + - "command. Give it a moment: replies are left to settle briefly so an edit " + - "straight after posting is the version the agent reads.\n\n" + - "Do not edit the description: that replaces what this paused session was " + - "already given, and only a run you name yourself can proceed across such a " + - "change.\n\n" + - "If you are not a trusted author, your reply still needs a decision — set " + - $"\"{surface.ContextApprovalField}\" to any other value and back to " + - $"\"{surface.ApprovedOption}\", both moves, since approval is an instant and " + - "re-selecting the value it already holds moves nothing.") - : new WorkerOperatorAction( - "Answer on the issue — no CLI needed", - [url], - "1. Reply in a new comment on this issue with the clarification. Do not edit " + - "the description: that replaces what this paused session was already given, " + - "and only a run you name yourself can proceed across such a change.\n" + - $"2. Set \"{surface.ContextApprovalField}\" to any other value and back to " + - $"\"{surface.ApprovedOption}\" — both moves. Approval is an instant, so " + - "re-selecting the value it already holds moves nothing and your reply stays " + - "undecided.\n\n" + - "Your reply then reaches the agent as an addition to what it already holds, " + - "which any worker may carry to it.")); - actions.Add(new WorkerOperatorAction( - continuesAutomatically - ? $"Or start {agentLabel} yourself" - : $"Then start {agentLabel} again", - [$"wrighty worker --item {id.Value} --yes"], - $"Runs it now, reusing the recorded session. To keep it hands-off instead, set " + - $"\"{surface.DispatchStateField}\" to \"{DispatchStates.Queued}\" and leave it: a " + - "continuous worker takes the item once this claim lapses, and only on the host " + - "that recorded the session.")); - } - else - { - actions.Add(new WorkerOperatorAction( - "Edit the requirements in the web UI", - ["wrighty web"], - $"Open {id.Value}, then take over (or claim after expiry) and edit it. Choose Save " + - $"and resume automatically to let a continuous worker continue it, Save and show " + - $"manual {agentLabel} resume command under More actions to continue it yourself, " + - "Finish when complete, or Archive to close it without more agent work.")); - // Not --requeue. Rewriting the description supersedes the approved context the paused - // session already holds, and a continuous worker refuses to resume a session across a - // change nobody named the item to approve — so pairing the two queues a run that is - // certain to be refused. Naming the item is what carries that judgement, so the - // clarification and the run are two commands here rather than one. This backend has no - // discussion to append to, so rewriting is the only way to clarify it. - actions.Add(new WorkerOperatorAction( - "Clarify the requirements, then continue the session yourself", - [ - $"wrighty edit {id.Value} --takeover --yes --body-file requirements.md", - $"wrighty worker --item {id.Value} --yes" - ], - "The first saves the clarification and ends human ownership. The second resumes " + - "the recorded session: because you named the item, Wrighty proceeds despite the " + - "changed description and reports that it did.")); - } - - var ownershipDescription = activeUntil is null - ? "There is no active claimant to displace, so Wrighty acquires a human editing claim." - : $"The current claim is active until {activeUntil:O}. edit --takeover works before or " + - "after that time: while active, Wrighty asks you to confirm displacing the current " + - "claimant; after expiry, it acquires a human editing claim without prompting. The " + - "recorded local agent session is preserved in either case."; - var editWarning = surface.HasDiscussion - ? " Editing the description this way replaces what the session already holds, so only " + - "a run you name for this item will proceed across it — prefer a comment above." - : string.Empty; - actions.Add(new WorkerOperatorAction( - "Take the item over for editing", - [ - $"wrighty edit {id.Value} --takeover", - $"wrighty edit {id.Value} --takeover --yes --title \"Clear title\" " + - "--body-file requirements.md" - ], - $"{ownershipDescription} The first command opens the title and body in VISUAL or " + - "EDITOR. The second is the non-interactive form. Both retain the claim handle inside " + - $"Wrighty.{editWarning}")); - return actions; - } + ApprovedContext.TrustedContinuationBudget? budget = null) => + Actions.OperationalActionGuidance.NeedsAttentionActions(id, agentName, surface, activeUntil, budget); /// /// The item's continuation budget as the guidance needs it, or null when it cannot change the diff --git a/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs b/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs new file mode 100644 index 0000000..89ca79e --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs @@ -0,0 +1,196 @@ +using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.Claims; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Models; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.UnitTests.Actions; + +public sealed class OperationalActionResolverTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-14T10:00:00Z"); + private static readonly TrackerConfig Config = new() + { + Backend = "local-markdown", + DefaultPickFrom = "Automation", + DefaultPickTo = "Doing", + DefaultFinishTo = "Complete", + LocalMarkdown = new() { Statuses = ["Ideas", "Automation", "Doing", "Complete"] } + }; + + [Theory] + [InlineData("Ideas", "queue", null)] + [InlineData("Automation", "queue", "WORKFLOW_STATE_INVALID")] + [InlineData("Doing", "queue", "WORKFLOW_STATE_INVALID")] + [InlineData("Complete", "queue", "WORKFLOW_STATE_INVALID")] + [InlineData("Automation", "send-back", null)] + [InlineData("Ideas", "send-back", "WORKFLOW_STATE_INVALID")] + public void Board_actions_use_configured_roles(string status, string action, string? code) + { + var result = Resolve(State(status)); + Assert.Equal(code, Find(result, action).UnavailableCode); + Assert.Null(result.RecommendedAction); + Assert.Contains("Ideas", Find(result, "send-back").Description); + } + + [Fact] + public void Queue_policy_disabled_does_not_promise_automatic_authorization() + { + var result = Resolve(State(), Config with { Worker = new() { UseWorkerQueue = false } }); + Assert.Contains("independent", Find(result, "queue").Description); + Assert.DoesNotContain("revokes", Find(result, "send-back").Description); + } + + [Fact] + public void Missing_backlog_and_recovery_state_block_moves() + { + var config = Config with { LocalMarkdown = new() { Statuses = ["Automation", "Doing", "Complete"] } }; + Assert.Equal("STATUS_UNAVAILABLE", Find(Resolve(State("Automation"), config), "send-back").UnavailableCode); + var state = State() with { Item = State().Item with { DispatchState = DispatchStates.RetryScheduled } }; + Assert.Equal("WORKER_RECOVERY_PENDING", Find(Resolve(state), "queue").UnavailableCode); + } + + [Theory] + [InlineData(ClaimOwnershipState.OwnedByCurrent)] + [InlineData(ClaimOwnershipState.HeldByOther)] + public void Active_claim_blocks_queue_without_takeover(ClaimOwnershipState ownership) + { + var state = State() with { Claim = new(ownership) }; + Assert.Equal("CLAIM_HELD", Find(Resolve(state), "queue").UnavailableCode); + Assert.Equal("CLAIM_HELD", Find(Resolve(state), "clarify").UnavailableCode); + } + + [Fact] + public void Archived_item_retains_review_but_blocks_mutations() + { + var state = State() with { Item = State().Item with { Archived = true } }; + var result = Resolve(state); + Assert.Equal("available", Find(result, "open-item").Availability); + Assert.Equal("ITEM_ARCHIVED", Find(result, "queue").UnavailableCode); + Assert.Equal("ITEM_ARCHIVED", Find(result, "clarify").UnavailableCode); + Assert.Equal("WORKER_ITEM_TERMINAL", Find(result, "continue-worker").UnavailableCode); + } + + [Fact] + public void Attention_recommends_clarification_and_exposes_distinct_resume_actions() + { + var state = Paused() with { OperationalStatus = OperationalStatuses.NeedsAttention }; + var result = Resolve(state); + Assert.Equal("clarify", result.RecommendedAction); + Assert.True(result.Actions[0].Recommended); + Assert.Equal("available", Find(result, "resume").Availability); + Assert.Empty(Find(result, "resume").Commands); + Assert.True(Find(result, "continue-worker").StartsProcess); + Assert.False(Find(result, "resume").StartsProcess); + Assert.Equal("required", Find(result, "resume").Confirmation); + } + + [Fact] + public void Resume_needs_execution_policy_and_active_work_status() + { + var state = Paused() with { OperationalStatus = OperationalStatuses.NeedsAttention }; + state = state with { Item = state.Item with { AutomaticExecutionAllowed = false } }; + Assert.Equal("WORKER_ITEM_INELIGIBLE", Find(Resolve(state), "resume").UnavailableCode); + state = state with { Item = state.Item with { AutomaticExecutionAllowed = true, Status = "Ideas" } }; + Assert.Equal("WORKER_ITEM_INELIGIBLE", Find(Resolve(state), "resume").UnavailableCode); + } + + [Theory] + [InlineData("missing", "RESUME_ADDRESS_UNAVAILABLE")] + [InlineData("incomplete", "RESUME_ADDRESS_UNAVAILABLE")] + [InlineData("remote", "RESUME_ADDRESS_NOT_LOCAL")] + [InlineData("worktree", "RESUME_WORKTREE_ABSENT")] + [InlineData("other-claim", "CLAIM_NOT_OWNER")] + [InlineData("active", "CLAIM_HELD")] + public void Unusable_sessions_do_not_advertise_launch(string scenario, string code) + { + var state = Paused(); + state = scenario switch + { + "missing" => state with { Session = null }, + "incomplete" => state with { Session = state.Session! with { SessionId = null } }, + "remote" => state with { Session = state.Session! with { FromCurrentInstallation = false } }, + "other-claim" => state with { Claim = new(ClaimOwnershipState.HeldByOther) }, + "active" => state with { OperationalStatus = OperationalStatuses.AgentActive }, + _ => state + }; + var result = Resolve(state, workspaceExists: scenario != "worktree"); + Assert.Equal(code, Find(result, "resume-session").UnavailableCode); + Assert.Equal(code, Find(result, "continue-worker").UnavailableCode); + Assert.Equal("available", Find(result, "open-item").Availability); + } + + [Fact] + public void Missing_admission_fails_closed_without_hiding_review() + { + var result = OperationalActionResolver.Resolve(new(Config, Paused(), Now, true)); + Assert.Equal("ACTION_STATE_UNVERIFIED", Find(result, "continue-worker").UnavailableCode); + Assert.Equal("ACTION_STATE_UNVERIFIED", Find(result, "resume-session").UnavailableCode); + Assert.Equal("available", Find(result, "open-item").Availability); + } + + [Theory] + [InlineData("AGENT_DISABLED")] + [InlineData("AGENT_NOT_INSTALLED")] + [InlineData("CONTEXT_NOT_APPROVED")] + public void Admission_refusals_are_preserved(string code) + { + var result = OperationalActionResolver.Resolve(new(Config, Paused(), Now, true, + new(code, "Blocked by authoritative preflight."), ActionAvailability.Available)); + Assert.Equal(code, Find(result, "continue-worker").UnavailableCode); + Assert.Equal("available", Find(result, "resume-session").Availability); + } + + [Theory] + [InlineData(OperationalStatuses.RetryScheduled, "retry-now")] + [InlineData(OperationalStatuses.HandoffQueued, "continue-worker")] + public void Deferred_work_never_recommends_overriding_the_schedule(string status, string action) + { + var result = Resolve(Paused() with { OperationalStatus = status }); + Assert.Null(result.RecommendedAction); + Assert.Equal("available", Find(result, action).Availability); + Assert.All(result.Actions, value => Assert.Equal("manual-only", value.Execution)); + Assert.Equal(result.Actions.Count, result.Actions.Select(value => value.Name).Distinct().Count()); + } + + [Fact] + public void GitHub_uses_links_and_preserves_clarification_guidance() + { + var state = Paused() with { OperationalStatus = OperationalStatuses.NeedsAttention }; + state = state with { Item = state.Item with { Url = "https://github.com/o/r/issues/42" } }; + var result = Resolve(state, Config with { Backend = "github" }); + Assert.Equal("answer-on-issue", result.RecommendedAction); + var answer = Find(result, "answer-on-issue"); + Assert.Equal(state.Item.Url, answer.Url); + Assert.Empty(answer.Commands); + Assert.Contains("new comment", answer.Description); + Assert.Equal("NOT_SUPPORTED", Find(result, "queue").UnavailableCode); + } + + [Fact] + public void Discovery_and_handover_share_clarification_and_retry_guidance() + { + var state = Paused() with { OperationalStatus = OperationalStatuses.NeedsAttention }; + var guidance = OperationalActionGuidance.NeedsAttentionActions(state.Item.Id, "claude", + OperatorSurface.For(Config, null)).Single(value => value.Name == "clarify"); + var action = Find(Resolve(state), "clarify"); + Assert.Equal(guidance.Description, action.Description); + Assert.Equal(guidance.Commands, action.Commands); + var retry = Find(Resolve(state with { OperationalStatus = OperationalStatuses.RetryScheduled }), "retry-now"); + Assert.Equal(OperationalActionGuidance.RetryNow(state.Item.Id).Commands, retry.Commands); + } + + private static OperationalActionDiscovery Resolve(WorkItemOperationalState state, + TrackerConfig? config = null, bool workspaceExists = true) => OperationalActionResolver.Resolve( + new(config ?? Config, state, Now, workspaceExists, ActionAvailability.Available, ActionAvailability.Available)); + private static OperationalAction Find(OperationalActionDiscovery result, string name) => + Assert.Single(result.Actions, action => action.Name == name); + private static WorkItemOperationalState State(string status = "Ideas") => new( + new(new("local:42"), "Work", "Requirements", null, status, "P1", AutomaticExecutionAllowed: true), + new(ClaimOwnershipState.Unclaimed), null, OperationalStatuses.None); + private static WorkItemOperationalState Paused() => State("Doing") with + { + OperationalStatus = OperationalStatuses.PausedSession, + Session = new("claude", "session-42", "/workspace", Now, true) + }; +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs new file mode 100644 index 0000000..a2bcd2e --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using Highbyte.Wrighty.Claims; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.UnitTests.Cli; + +public sealed partial class CliApplicationTests +{ + [Fact] + public async Task Actions_json_is_read_only_and_resolves_canonical_ids() + { + var backend = new RecordingBackend(); + var output = new StringWriter(); + var exit = await Application(backend, new StringReader(""), output, workerCandidate: true) + .InvokeAsync(["actions", "42", "--json"]); + Assert.Equal(0, exit); + using var json = JsonDocument.Parse(output.ToString()); + Assert.Equal(1, json.RootElement.GetProperty("schemaVersion").GetInt32()); + var result = json.RootElement.GetProperty("result"); + Assert.Equal("github:owner/repo#42", result.GetProperty("itemId").GetString()); + var actions = result.GetProperty("actions").EnumerateArray().ToArray(); + Assert.All(actions, action => + { + Assert.Equal("available", action.GetProperty("availability").GetString()); + Assert.Equal("manual-only", action.GetProperty("execution").GetString()); + }); + Assert.Null(backend.Patch); + Assert.Null(backend.Operation); + Assert.DoesNotContain("claimToken", output.ToString()); + } + + [Fact] + public async Task Actions_all_explains_blocked_alternatives_and_separates_urls() + { + var output = new StringWriter(); + var exit = await Application(new RecordingBackend(), new StringReader(""), output, workerCandidate: true) + .InvokeAsync(["actions", "42", "--all", "--json"]); + Assert.Equal(0, exit); + using var json = JsonDocument.Parse(output.ToString()); + var actions = json.RootElement.GetProperty("result").GetProperty("actions").EnumerateArray().ToArray(); + var queue = actions.Single(action => action.GetProperty("name").GetString() == "queue"); + Assert.Equal("NOT_SUPPORTED", queue.GetProperty("unavailableCode").GetString()); + var review = actions.Single(action => action.GetProperty("name").GetString() == "open-item"); + Assert.Equal(0, review.GetProperty("commands").GetArrayLength()); + Assert.Equal("https://github.com/owner/repo/issues/42", review.GetProperty("url").GetString()); + } + + [Theory] + [InlineData("made-up", "ACTION_UNKNOWN")] + [InlineData("resume-session", "RESUME_ADDRESS_UNAVAILABLE")] + [InlineData("queue", "NOT_SUPPORTED")] + public async Task Selecting_unknown_or_unavailable_action_returns_structured_error(string name, string code) + { + var output = new StringWriter(); + var error = new StringWriter(); + var exit = await Application(new RecordingBackend(), new StringReader(""), output, error, workerCandidate: true) + .InvokeAsync(["actions", "42", name, "--json"]); + Assert.NotEqual(0, exit); + using var json = JsonDocument.Parse(error.ToString()); + Assert.Equal(code, json.RootElement.GetProperty("error").GetProperty("code").GetString()); + } + + [Fact] + public async Task Selected_available_action_is_the_only_result() + { + var output = new StringWriter(); + Assert.Equal(0, await Application(new RecordingBackend(), new StringReader(""), output) + .InvokeAsync(["actions", "42", "open-item", "--json"])); + using var json = JsonDocument.Parse(output.ToString()); + var action = Assert.Single(json.RootElement.GetProperty("result").GetProperty("actions").EnumerateArray()); + Assert.Equal("open-item", action.GetProperty("name").GetString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Execution_is_refused_before_any_work_is_attempted(bool selected) + { + var backend = new RecordingBackend(); + var output = new StringWriter(); + var error = new StringWriter(); + string[] args = selected ? ["actions", "42", "clarify", "--exec", "--json"] + : ["actions", "42", "--exec", "--json"]; + Assert.Equal(2, await Application(backend, new StringReader("yes"), output, error).InvokeAsync(args)); + using var json = JsonDocument.Parse(error.ToString()); + Assert.Equal("ACTION_EXECUTION_UNSUPPORTED", json.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.Null(backend.Patch); + } + + [Fact] + public async Task Remote_session_is_not_advertised_as_locally_resumable() + { + var output = new StringWriter(); + var session = new AgentSessionRecord("claude", "secret-session", Directory.GetCurrentDirectory(), + DateTimeOffset.UtcNow, false); + Assert.Equal(0, await Application(new RecordingBackend(), new StringReader(""), output, + workerCandidate: true, unclaimedSession: session).InvokeAsync(["actions", "42", "--all", "--json"])); + using var json = JsonDocument.Parse(output.ToString()); + var action = json.RootElement.GetProperty("result").GetProperty("actions").EnumerateArray() + .Single(value => value.GetProperty("name").GetString() == "resume-session"); + Assert.Equal("RESUME_ADDRESS_NOT_LOCAL", action.GetProperty("unavailableCode").GetString()); + Assert.DoesNotContain("secret-session", output.ToString()); + } + + [Fact] + public async Task Get_exposes_the_same_discovery_contract() + { + var output = new StringWriter(); + var app = Application(new RecordingBackend(), new StringReader(""), output, workerCandidate: true); + Assert.Equal(0, await app.InvokeAsync(["get", "42", "--json"])); + using var get = JsonDocument.Parse(output.ToString()); + var expected = get.RootElement.GetProperty("result").GetProperty("actions"); + output.GetStringBuilder().Clear(); + Assert.Equal(0, await app.InvokeAsync(["actions", "42", "--all", "--json"])); + using var actions = JsonDocument.Parse(output.ToString()); + Assert.True(JsonElement.DeepEquals(expected, actions.RootElement.GetProperty("result"))); + } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Runtime_inspection_failure_only_blocks_local_launch_actions(bool denied) + { + var output = new StringWriter(); + var session = new AgentSessionRecord("claude", "session", Directory.GetCurrentDirectory(), + DateTimeOffset.UtcNow, true); + IAgentRuntimeCatalog runtimes = denied ? new DeniedRuntimeCatalog() : new FixedRuntimeCatalog(); + Assert.Equal(0, await Application(new RecordingBackend(), new StringReader(""), output, + workerCandidate: true, unclaimedSession: session, runtimeCatalog: runtimes) + .InvokeAsync(["actions", "42", "--all", "--json"])); + using var json = JsonDocument.Parse(output.ToString()); + var actions = json.RootElement.GetProperty("result").GetProperty("actions").EnumerateArray().ToArray(); + var resume = actions.Single(value => value.GetProperty("name").GetString() == "resume-session"); + Assert.Equal(denied ? "ACTION_STATE_UNVERIFIED" : "AGENT_NOT_INSTALLED", + resume.GetProperty("unavailableCode").GetString()); + var review = actions.Single(value => value.GetProperty("name").GetString() == "open-item"); + Assert.Equal("available", review.GetProperty("availability").GetString()); + } + + [Fact] + public async Task Human_discovery_labels_inert_guidance_and_links() + { + var output = new StringWriter(); + Assert.Equal(0, await Application(new RecordingBackend(), new StringReader(""), output, + workerCandidate: true).InvokeAsync(["actions", "42", "--all"])); + Assert.Contains("Discovery only", output.ToString()); + Assert.Contains("manual-only", output.ToString()); + Assert.Contains("Link: https://github.com/owner/repo/issues/42", output.ToString()); + Assert.Contains("RESUME_ADDRESS_UNAVAILABLE", output.ToString()); + } + + private sealed class DeniedRuntimeCatalog : IAgentRuntimeCatalog + { + public AgentRuntimeSnapshot Snapshot() => throw new UnauthorizedAccessException("Denied"); + } + +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs index 0de795c..4b71fbd 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs @@ -25,7 +25,7 @@ namespace Highbyte.Wrighty.UnitTests.Cli; -public sealed class CliApplicationTests : IDisposable +public sealed partial class CliApplicationTests : IDisposable { /// /// Temporary settings roots created by , removed when xUnit diff --git a/tests/Highbyte.Wrighty.UnitTests/Output/OutputWriterTests.cs b/tests/Highbyte.Wrighty.UnitTests/Output/OutputWriterTests.cs index c3a1fec..33c6b97 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Output/OutputWriterTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Output/OutputWriterTests.cs @@ -1,3 +1,4 @@ +using Highbyte.Wrighty.Actions; using System.Text.Json; using Highbyte.Wrighty.Cli; using Highbyte.Wrighty.Cli.Output; @@ -127,7 +128,7 @@ await writer.WriteStatusAsync( var human = output.ToString(); Assert.Contains("Needs attention (1)", human); Assert.Contains("last run: succeeded — Need the API key.", human); - Assert.Contains("wrighty edit local:1 --takeover", human); + Assert.Contains("wrighty actions local:1", human); Assert.Contains("Completed — retained worktree (1)", human); Assert.Contains("branch feature/b (dirty, unmerged)", human); Assert.Contains("Queued (1)", human); @@ -920,7 +921,8 @@ public async Task Human_operational_detail_shows_worker_claim_session_and_action new WorkItemOperationalState( item, claim, session, OperationalStatuses.NeedsAttention), json: false, - _ => "#42"); + _ => "#42", + actionDiscovery: DiscoverForOutput(new(item, claim, session, OperationalStatuses.NeedsAttention))); var text = output.ToString(); Assert.Contains("#42 Needs clarification", text); @@ -1166,25 +1168,29 @@ public async Task Operational_actions_point_github_items_at_the_issue_url_not_th var now = DateTimeOffset.Parse("2026-07-22T12:00:00Z"); var session = new AgentSessionRecord("codex", "s1", "/tmp/ws", now.AddMinutes(30), true); - // A GitHub item (carries a URL) must point at the issue, never the Local-Markdown-only web UI. + var githubState = State(OperationalStatuses.NeedsAttention, ClaimOwnershipState.OwnedByCurrent, + now.AddMinutes(30), "agent:worker:1", session, url: "https://github.com/o/r/issues/1"); var githubOut = new StringWriter(); await new OutputWriter(githubOut, new StringWriter(), () => now).WriteOperationalDetailAsync( - State(OperationalStatuses.NeedsAttention, ClaimOwnershipState.OwnedByCurrent, - now.AddMinutes(30), "agent:worker:1", session, url: "https://github.com/o/r/issues/1"), - json: false, _ => "#1"); - var githubText = githubOut.ToString(); - Assert.Contains("Review on GitHub: https://github.com/o/r/issues/1", githubText); - Assert.DoesNotContain("wrighty web", githubText); + githubState, json: false, _ => "#1", actionDiscovery: DiscoverForOutput(githubState)); + Assert.Contains("Link: https://github.com/o/r/issues/1", githubOut.ToString()); + Assert.DoesNotContain("wrighty web", githubOut.ToString()); - // A Local Markdown item (no URL) keeps the web-UI action. + var localState = State(OperationalStatuses.NeedsAttention, ClaimOwnershipState.OwnedByCurrent, + now.AddMinutes(30), "agent:worker:1", session); var localOut = new StringWriter(); await new OutputWriter(localOut, new StringWriter(), () => now).WriteOperationalDetailAsync( - State(OperationalStatuses.NeedsAttention, ClaimOwnershipState.OwnedByCurrent, - now.AddMinutes(30), "agent:worker:1", session), - json: false, _ => "#1"); - Assert.Contains("Open web UI: wrighty web", localOut.ToString()); + localState, json: false, _ => "#1", actionDiscovery: DiscoverForOutput(localState)); + Assert.Contains("open-item: Open item in the web console", localOut.ToString()); + Assert.Contains("wrighty web", localOut.ToString()); } + private static OperationalActionDiscovery DiscoverForOutput(WorkItemOperationalState state) => + OperationalActionResolver.Resolve(new( + new TrackerConfig { Backend = state.Item.Url is null ? "local-markdown" : "github" }, + state, DateTimeOffset.Parse("2026-09-14T10:00:00Z"), true, + ActionAvailability.Available, ActionAvailability.Available)); + [Fact] public async Task Detail_output_surfaces_backend_neutral_custom_fields() {