Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions docs/reference/actions.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/reference/operator-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/worker.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ claim state, remaining lease, and any resumable session. A worker-originated act
as `<Agent> 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:
Expand Down
127 changes: 127 additions & 0 deletions src/Highbyte.Wrighty.Cli/CliActionCommands.cs
Original file line number Diff line number Diff line change
@@ -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<string?>("action-name") { Arity = ArgumentArity.ZeroOrOne };
var all = new Option<bool>("--all") { Description = "Include unavailable actions and their reasons." };
var json = JsonOption();
var exec = new Option<bool>("--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<OperationalActionDiscovery> 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<ActionAvailability> 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<string, string>(), 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;
}
}

}
11 changes: 9 additions & 2 deletions src/Highbyte.Wrighty.Cli/CliApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Actions.OperationalActionDiscovery>(StringComparer.Ordinal);
foreach (var item in items)
actions[item.Item.Id.Value] = await DiscoverActionsAsync(config, item, cancellationToken);

await writer.WriteStatusAsync(
items,
workspaceStatuses,
Expand All @@ -2992,7 +2998,8 @@ config.SourcePath is null
? []
: await workerInstances.ListAsync(config.SourcePath, cancellationToken),
configurationRevision,
PendingInterruptions(config)));
PendingInterruptions(config),
actions));
}

private async Task<IReadOnlyDictionary<string, WorkspaceStatusResult>> StatusWorkspaceStatusesAsync(
Expand Down
42 changes: 42 additions & 0 deletions src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
Loading