diff --git a/.github/workflows/nitro-cli-e2e.yml b/.github/workflows/nitro-cli-e2e.yml
new file mode 100644
index 00000000000..592fd60844a
--- /dev/null
+++ b/.github/workflows/nitro-cli-e2e.yml
@@ -0,0 +1,197 @@
+name: nitro-cli-e2e
+
+on:
+ push:
+ paths:
+ - 'src/Nitro/CommandLine/**'
+ - 'src/Nitro/Common/**'
+ - '.github/workflows/nitro-cli-e2e.yml'
+ - 'global.json'
+ - 'src/Directory.Packages.props'
+ pull_request:
+ paths:
+ - 'src/Nitro/CommandLine/**'
+ - 'src/Nitro/Common/**'
+ - '.github/workflows/nitro-cli-e2e.yml'
+ - 'global.json'
+ - 'src/Directory.Packages.props'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: nitro-cli-e2e-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ aot-publish-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: Install .NET
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 11.0.100-preview.6.26359.118
+
+ - name: Publish AOT self-contained binary (linux-x64)
+ run: |
+ dotnet publish src/Nitro/CommandLine/src/CommandLine \
+ -c Release \
+ -r linux-x64 \
+ -f net11.0 \
+ -o publish \
+ --self-contained \
+ -p:PublishAot=true \
+ -p:TargetFrameworks=net11.0 \
+ -p:RuntimeIdentifiers=linux-x64
+
+ - name: Smoke test the published binary
+ run: |
+ set -euo pipefail
+
+ nitro_bin="$(pwd)/publish/nitro"
+ "$nitro_bin" --version
+
+ work_dir="$(mktemp -d)"
+ cd "$work_dir"
+ export NITRO_TASK_ACTOR=ci
+
+ "$nitro_bin" agent tasks init
+ "$nitro_bin" agent tasks list
+
+ create_output="$("$nitro_bin" agent tasks create "AOT smoke test task")"
+ echo "$create_output"
+ task_id=$(printf '%s' "$create_output" | sed -n "s/.*'\([^']*\)'.*/\1/p")
+ if [ -z "$task_id" ]; then
+ echo "::error::Could not parse a task ID out of the create output above." >&2
+ exit 1
+ fi
+
+ "$nitro_bin" agent tasks show "$task_id"
+
+ record-and-verify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: Install .NET
+ uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ with:
+ dotnet-version: 11.0.100-preview.6.26359.118
+
+ - name: Record + verify all flows
+ id: record
+ continue-on-error: true
+ run: ./src/Nitro/CommandLine/test/e2e/run.sh
+
+ - name: Upload snapshot artifacts
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: nitro-e2e-snapshots
+ path: |
+ src/Nitro/CommandLine/test/e2e/out/*.gif
+ src/Nitro/CommandLine/test/e2e/out/report/**
+ if-no-files-found: warn
+
+ - name: Comment changed snapshots on PR
+ if: always() && github.event_name == 'pull_request'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ RUN_ID: ${{ github.run_id }}
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const pr = context.payload.pull_request;
+ const prNum = pr.number;
+ const baseRef = pr.base.ref;
+ const MARKER = '';
+ const bust = `?run=${process.env.RUN_ID}`;
+
+ const rawBase = `https://raw.githubusercontent.com/${pr.base.repo.full_name}/${pr.base.sha}`;
+ const headFull = pr.head.repo && pr.head.repo.full_name;
+ const rawHead = headFull
+ ? `https://raw.githubusercontent.com/${headFull}/${pr.head.sha}`
+ : null;
+
+ const files = await github.paginate(github.rest.pulls.listFiles,
+ { owner, repo, pull_number: prNum, per_page: 100 });
+ const re = /^src\/Nitro\/CommandLine\/test\/e2e\/([^/]+)-flow\.gif$/;
+ const changed = [];
+ for (const f of files) {
+ const m = f.filename.match(re);
+ if (m) changed.push(
+ { flow: m[1], status: f.status, path: f.filename, prev: f.previous_filename });
+ }
+ changed.sort((a, b) => a.flow.localeCompare(b.flow));
+
+ async function upsertComment(body) {
+ const { data: comments } = await github.rest.issues.listComments(
+ { owner, repo, issue_number: prNum, per_page: 100 });
+ const existing = comments.find(c => c.body && c.body.includes(MARKER));
+ if (existing) {
+ await github.rest.issues.updateComment(
+ { owner, repo, comment_id: existing.id, body });
+ } else {
+ await github.rest.issues.createComment(
+ { owner, repo, issue_number: prNum, body });
+ }
+ }
+
+ if (changed.length === 0) {
+ try {
+ await upsertComment(
+ `${MARKER}\nโ
**Nitro CLI E2E snapshots**: no flow recordings changed vs \`${baseRef}\`.`);
+ } catch (e) {
+ core.warning(`comment failed (expected on forks): ${e}`);
+ }
+ return;
+ }
+
+ const img = url => `
`;
+ const ICON = { added: '๐', removed: '๐๏ธ' };
+
+ const blocks = [];
+ for (const { flow, status, path, prev } of changed) {
+ const before = `${rawBase}/${prev || path}`;
+ const after = rawHead ? `${rawHead}/${path}` : null;
+ let inner;
+ if (status === 'added') {
+ inner = after ? `\n\n${img(after)}\n` : '\n\n_after unavailable_\n';
+ } else if (status === 'removed') {
+ inner = `\n\n${img(before)}\n`;
+ } else {
+ const afterCell = after ? img(after) : 'unavailable';
+ inner =
+ `\n\n
Before: ${baseRef} | After: this PR |
`
+ + `| ${img(before)} | ${afterCell} |
\n`;
+ }
+ blocks.push(
+ `${ICON[status] || '๐'} ${flow}: ${status}
${inner} `);
+ }
+
+ const body = [
+ MARKER,
+ '### ๐ฌ Nitro CLI E2E snapshots changed',
+ '',
+ `These flow recordings differ from \`${baseRef}\` (added, removed, or modified). `
+ + 'Expand each for the before/after.',
+ '',
+ ...blocks,
+ '',
+ `Run ${process.env.RUN_ID} ยท full set in the nitro-e2e-snapshots artifact.`,
+ ].join('\n');
+
+ try { await upsertComment(body); }
+ catch (e) { core.warning(`comment failed (expected on forks): ${e}`); }
+
+ - name: Fail if any flow drifted from its golden
+ if: always() && steps.record.outcome == 'failure'
+ run: |
+ echo "::error::E2E recordings drifted from their committed golden, review the nitro-e2e-snapshots artifact, then run ./src/Nitro/CommandLine/test/e2e/run.sh --update if intended." >&2
+ exit 1
diff --git a/src/All.slnx b/src/All.slnx
index 63155413f31..30ed27cbf90 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -404,6 +404,7 @@
+
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index a2219d370d4..869af9ffab7 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -19,7 +19,10 @@
+
+
+
diff --git a/src/Nitro/CommandLine/Nitro.CommandLine.slnx b/src/Nitro/CommandLine/Nitro.CommandLine.slnx
index d66da32314c..f2d3a834e7d 100644
--- a/src/Nitro/CommandLine/Nitro.CommandLine.slnx
+++ b/src/Nitro/CommandLine/Nitro.CommandLine.slnx
@@ -5,5 +5,6 @@
+
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs
new file mode 100644
index 00000000000..dcae8536330
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs
@@ -0,0 +1,13 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Agent;
+
+internal sealed class AgentCommand : Command
+{
+ public AgentCommand() : base("agent")
+ {
+ Description = "Commands for coding agents.";
+
+ Subcommands.Add(new TasksCommand());
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs
index 688eafde016..d68418dfdaa 100644
--- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs
@@ -605,8 +605,8 @@ private static async Task ComposeAsync(
if (removeSourceSchemas.Count > 0)
{
- var existing = (await archive.GetSourceSchemaNamesAsync(cancellationToken))
- .ToHashSet(StringComparer.Ordinal);
+ var sourceSchemaNames = await archive.GetSourceSchemaNamesAsync(cancellationToken);
+ var existing = sourceSchemaNames.ToHashSet(StringComparer.Ordinal);
var missingSourceSchema =
removeSourceSchemas.FirstOrDefault(name => !existing.Contains(name));
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs
new file mode 100644
index 00000000000..1c994dac85d
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs
@@ -0,0 +1,52 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class AddTaskCommentCommand : Command
+{
+ public AddTaskCommentCommand() : base("add")
+ {
+ Description = "Add a comment to a task.";
+
+ Arguments.Add(Opt.Instance);
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks comment add \"acme-1a2\" \"Looks good to me.\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+ var text = parseResult.GetRequiredValue(Opt.Instance);
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var comment = await store.AddCommentAsync(id, text, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(comment));
+ return ExitCodes.Success;
+ }
+
+ console.OkLine($"Added comment to '{comment.TaskId.EscapeMarkup()}'.");
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs
new file mode 100644
index 00000000000..42cbc21e155
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs
@@ -0,0 +1,77 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class AddTaskDependencyCommand : Command
+{
+ public AddTaskDependencyCommand() : base("add")
+ {
+ Description = "Add a dependency between two tasks.";
+
+ Arguments.Add(Opt.Instance);
+ Arguments.Add(Opt.Instance);
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples(
+ "agent tasks dep add \"acme-1a2\" \"acme-9z8\"",
+ "agent tasks dep add \"acme-1a2\" \"acme-9z8\" --type waits-for");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+ var dependsOnId = parseResult.GetRequiredValue(Opt.Instance);
+ var typeValue = parseResult.GetValue(Opt.Instance);
+ var type = typeValue is null
+ ? TaskDependencyTypes.Blocks
+ : TaskDependencyTypes.Normalize(typeValue);
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var result = await store.AddDependencyAsync(id, dependsOnId, type, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskDependencyAddedResult
+ {
+ Id = id,
+ DependsOnId = dependsOnId,
+ Type = type,
+ Cycle = result.Cycle
+ }));
+
+ return ExitCodes.Success;
+ }
+
+ console.OkLine(
+ $"Added {type.EscapeMarkup()} dependency: "
+ + $"'{id.EscapeMarkup()}' -> '{dependsOnId.EscapeMarkup()}'.");
+
+ return ExitCodes.Success;
+ }
+
+ public sealed record TaskDependencyAddedResult
+ {
+ public required string Id { get; init; }
+ public required string DependsOnId { get; init; }
+ public required string Type { get; init; }
+ public IReadOnlyList? Cycle { get; init; }
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs
new file mode 100644
index 00000000000..62130975205
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs
@@ -0,0 +1,64 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class AddTaskLabelCommand : Command
+{
+ public AddTaskLabelCommand() : base("add")
+ {
+ Description = "Add one or more labels to a task.";
+
+ Arguments.Add(Opt.Instance);
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks label add \"acme-1a2\" api parser");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+ var labels = parseResult.GetRequiredValue(Opt.Instance);
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var results = await store.AddLabelAsync(id, labels, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskLabelAddResult(id, results)));
+ return ExitCodes.Success;
+ }
+
+ foreach (var result in results)
+ {
+ if (result.Added)
+ {
+ console.OkLine($"Added label '{result.Label.EscapeMarkup()}' to '{id.EscapeMarkup()}'.");
+ }
+ else
+ {
+ console.WriteLine($"Label '{result.Label}' is already on '{id}'.");
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+
+ public sealed record TaskLabelAddResult(string Id, IReadOnlyList Labels);
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs
new file mode 100644
index 00000000000..fdfad499e4a
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs
@@ -0,0 +1,76 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class BlockedTaskCommand : Command
+{
+ public BlockedTaskCommand() : base("blocked")
+ {
+ Description = "List tasks that are blocked by unfinished dependencies.";
+
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks blocked");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var blocked = await store.ComputeBlockedAsync(cancellationToken);
+
+ var tasks = new List();
+
+ foreach (var id in blocked.Keys)
+ {
+ var task = await store.GetTaskAsync(id, cancellationToken);
+
+ if (task is not null && !TaskStates.IsTerminal(task.Status))
+ {
+ tasks.Add(task);
+ }
+ }
+
+ tasks = tasks.OrderBy(t => t.Id, StringComparer.Ordinal).ToList();
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(
+ tasks.Select(t => new TaskBlockedResult(
+ t.Id, t.Priority, t.Type, t.Status, t.Title, blocked[t.Id]))
+ .ToArray()));
+ return ExitCodes.Success;
+ }
+
+ if (tasks.Count == 0)
+ {
+ console.WriteLine("No blocked tasks.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var task in tasks)
+ {
+ console.WriteLine(FormatRow(task, blocked[task.Id]));
+ }
+
+ console.WriteLine();
+ console.WriteLine($"{tasks.Count} task(s)");
+
+ return ExitCodes.Success;
+ }
+
+ private static string FormatRow(TaskItem task, IReadOnlyList blockers)
+ => $"{task.Id} {TaskPriorities.Format(task.Priority)} {task.Type} "
+ + $"{task.Status} {task.Title} "
+ + $"(blocked by: {string.Join(", ", blockers)})";
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs
new file mode 100644
index 00000000000..b7f1c774dff
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs
@@ -0,0 +1,67 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+using ChilliCream.Nitro.CommandLine.Tui.Board;
+using ChilliCream.Nitro.CommandLine.Tui.Input;
+using ChilliCream.Nitro.CommandLine.Tui.Runtime;
+using ChilliCream.Nitro.CommandLine.Tui.Search;
+using ChilliCream.Nitro.CommandLine.Tui.Shell;
+using ChilliCream.Nitro.CommandLine.Tui.Tree;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class BoardTaskCommand : Command
+{
+ public BoardTaskCommand() : base("board")
+ {
+ Description = "Open the interactive task board.";
+
+ this.AddExamples("agent tasks board");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var timeProvider = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+
+ if (!console.IsInteractive)
+ {
+ throw new ExitException("agent tasks board requires an interactive terminal.");
+ }
+
+ var workspaceDirectory = store.FindWorkspaceDirectory()
+ ?? throw new ExitException("No task workspace found. Run `nitro agent tasks init` first.");
+
+ var actor = TaskActor.Resolve(null, environmentVariableProvider);
+ var loader = new BoardDataLoader(store, timeProvider);
+ var mode = new BoardMode(loader);
+ var searchMode = new SearchMode(store);
+ var treeView = new DependencyTreeView(store, rootId: "");
+ var dispatcher = new KeyDispatcher(KeyMap.CreateDefaultGlobal());
+ var shell = new TuiShell(
+ dispatcher,
+ mode,
+ console.Profile.Width,
+ console.Profile.Height,
+ searchMode,
+ treeView,
+ store,
+ actor);
+ var application = new TuiApplication(console);
+ var dbWatcher = new TaskDbWatcher(TaskWorkspace.GetDatabasePath(workspaceDirectory));
+
+ using var quitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ shell.QuitConfirmed += () => quitCts.Cancel();
+
+ await application.RunAsync(shell.Handle, shell.Render, quitCts.Token, [dbWatcher.RunAsync]);
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs
new file mode 100644
index 00000000000..d4379abf447
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs
@@ -0,0 +1,57 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class CloseEligibleTaskEpicCommand : Command
+{
+ public CloseEligibleTaskEpicCommand() : base("close-eligible")
+ {
+ Description = "Close every epic whose children are all closed.";
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks epic close-eligible");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var eligible = await store.CloseEligibleEpicsAsync(actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(eligible));
+ return ExitCodes.Success;
+ }
+
+ if (eligible.Count == 0)
+ {
+ console.WriteLine("No eligible epics.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var epic in eligible)
+ {
+ console.OkLine($"Closed epic '{epic.Id.EscapeMarkup()}'.");
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs
new file mode 100644
index 00000000000..ef6d0ba4f4c
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs
@@ -0,0 +1,61 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class CloseTaskCommand : Command
+{
+ public CloseTaskCommand() : base("close")
+ {
+ Description = "Close one or more tasks.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples(
+ "agent tasks close \"app-1a2\"",
+ "agent tasks close \"app-1a2\" \"app-9z8\" --reason \"Fixed in v2\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var ids = parseResult.GetRequiredValue(Opt.Instance)
+ .Distinct()
+ .ToArray();
+ var reason = parseResult.GetValue(Opt.Instance) ?? "";
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var tasks = await store.CloseTaskAsync(ids, reason, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(
+ new ListResult(tasks.Select(task => TaskSnapshotResult.From(task)).ToArray()));
+
+ return ExitCodes.Success;
+ }
+
+ foreach (var task in tasks)
+ {
+ console.OkLine($"Closed task '{task.Id.EscapeMarkup()}'.");
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs
new file mode 100644
index 00000000000..889029e0147
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs
@@ -0,0 +1,78 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class CountTaskCommand : Command
+{
+ public CountTaskCommand() : base("count")
+ {
+ Description = "Count tasks.";
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks count", "agent tasks count --by status");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var by = parseResult.GetValue(Opt.Instance);
+
+ if (string.IsNullOrEmpty(by))
+ {
+ var total = await store.CountTasksAsync(cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskTotalCountResult(total)));
+ return ExitCodes.Success;
+ }
+
+ console.WriteLine(total.ToString());
+
+ return ExitCodes.Success;
+ }
+
+ var dimension = ParseDimension(by);
+ var rows = await store.CountTasksByAsync(dimension, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(rows));
+ return ExitCodes.Success;
+ }
+
+ foreach (var row in rows)
+ {
+ console.WriteLine($"{row.Value} {row.Count}");
+ }
+
+ return ExitCodes.Success;
+ }
+
+ public sealed record TaskTotalCountResult(int Total);
+
+ private static TaskCountDimension ParseDimension(string by) => by.Trim().ToLowerInvariant() switch
+ {
+ "status" => TaskCountDimension.Status,
+ "type" => TaskCountDimension.Type,
+ "priority" => TaskCountDimension.Priority,
+ "assignee" => TaskCountDimension.Assignee,
+ "label" => TaskCountDimension.Label,
+ _ => throw new ExitException(
+ $"Invalid --by value '{by}'. Use status, type, priority, assignee, or label.")
+ };
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs
new file mode 100644
index 00000000000..5be32b58102
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs
@@ -0,0 +1,139 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class CreateTaskCommand : Command
+{
+ public CreateTaskCommand() : base("create")
+ {
+ Description = "Create a task.";
+
+ Arguments.Add(Opt.Instance);
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples(
+ "agent tasks create \"Fix the parser\"",
+ "agent tasks create \"Fix the parser\" --priority p1 --type bug --depends-on \"acme-9z8\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariables = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var title = parseResult.GetRequiredValue(Opt.Instance);
+
+ if (title.Trim().Length is 0 || title.Length > 500)
+ {
+ throw new ExitException("The title must be 1-500 characters.");
+ }
+
+ var priorityValue = parseResult.GetValue(Opt.Instance);
+ var priority = priorityValue is null
+ ? TaskPriorities.Medium
+ : TaskPriorities.Parse(priorityValue);
+
+ var typeValue = parseResult.GetValue(Opt.Instance);
+ var type = typeValue is null ? TaskTypes.Task : TaskTypes.Normalize(typeValue);
+
+ var dueValue = parseResult.GetValue(Opt.Instance);
+ var dueAt = dueValue is null
+ ? (DateTimeOffset?)null
+ : TaskDates.Parse(dueValue, Opt.Instance.Name);
+
+ var deferValue = parseResult.GetValue(Opt.Instance);
+ var deferUntil = deferValue is null
+ ? (DateTimeOffset?)null
+ : TaskDates.Parse(deferValue, Opt.Instance.Name);
+
+ var description = parseResult.GetValue(Opt.Instance) ?? "";
+ var assignee = parseResult.GetValue(Opt.Instance);
+ var estimate = parseResult.GetValue(Opt.Instance);
+ var labels = (parseResult.GetValue(Opt.Instance) ?? [])
+ .Select(label => label.Trim().ToLowerInvariant())
+ .Distinct()
+ .ToArray();
+
+ if (labels.Any(string.IsNullOrEmpty))
+ {
+ throw new ExitException("Labels must be non-empty.");
+ }
+
+ var dependsOn = parseResult.GetValue(Opt.Instance) ?? [];
+ var parentId = parseResult.GetValue(Opt.Instance);
+
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariables);
+
+ var dependencyRequests = dependsOn.Select(dependency =>
+ {
+ var separatorIndex = dependency.IndexOf(':');
+ var dependencyType = separatorIndex < 0
+ ? TaskDependencyTypes.Blocks
+ : TaskDependencyTypes.Normalize(dependency[..separatorIndex]);
+ var dependsOnId = separatorIndex < 0 ? dependency : dependency[(separatorIndex + 1)..];
+
+ return new TaskDependencyRequest(dependsOnId, dependencyType);
+ }).ToList();
+
+ var result = await store.CreateTaskAsync(
+ new TaskCreation
+ {
+ Title = title,
+ Description = description,
+ Priority = priority,
+ Type = type,
+ Assignee = assignee,
+ EstimatedMinutes = estimate,
+ DueAt = dueAt,
+ DeferUntil = deferUntil,
+ Labels = labels,
+ DependsOn = dependencyRequests,
+ ParentId = parentId,
+ Actor = actor
+ },
+ cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ var createdTask = await store.GetRequiredTaskAsync(result.Id, cancellationToken);
+
+ resultHolder.SetResult(
+ new ObjectResult(TaskSnapshotResult.From(createdTask, result.BlockedBy)));
+
+ return ExitCodes.Success;
+ }
+
+ console.OkLine($"Created task '{result.Id}': {title.EscapeMarkup()}.");
+
+ if (result.BlockedBy.Count > 0)
+ {
+ console.WriteLine($" Blocked by: {string.Join(", ", result.BlockedBy)}");
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs
new file mode 100644
index 00000000000..8c09c03e049
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs
@@ -0,0 +1,125 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class CyclesTaskDependencyCommand : Command
+{
+ public CyclesTaskDependencyCommand() : base("cycles")
+ {
+ Description = "Detect cycles among blocking dependencies.";
+
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks dep cycles");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var dependencyEdges = await store.GetDependencyEdgesAsync(cancellationToken);
+ var edges = dependencyEdges.Where(e => TaskDependencyTypes.IsBlocking(e.Type)).ToList();
+
+ var adjacency = edges
+ .GroupBy(e => e.TaskId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(e => e.DependsOnId)
+ .Distinct()
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList());
+
+ var cycles = FindCycles(adjacency);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(
+ cycles.Select(c => new TaskCycleResult(c)).ToArray()));
+ return ExitCodes.Success;
+ }
+
+ if (cycles.Count == 0)
+ {
+ console.WriteLine("No cycles found.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var cycle in cycles)
+ {
+ console.WriteLine(FormatCycle(cycle));
+ }
+
+ return ExitCodes.Success;
+ }
+
+ // Enumerates each elementary cycle exactly once: a search that starts at
+ // a given node only continues through nodes ordinally greater than or
+ // equal to it, so a cycle is only ever found while starting from its
+ // smallest member.
+ //
+ // Internal, not private: DoctorTaskCommand reuses this to report
+ // blocking-dependency cycles as part of its workspace health check.
+ internal static List> FindCycles(IReadOnlyDictionary> adjacency)
+ {
+ var cycles = new List>();
+
+ foreach (var start in adjacency.Keys.OrderBy(x => x, StringComparer.Ordinal))
+ {
+ var path = new List { start };
+ var onPath = new HashSet { start };
+
+ Walk(start, start, adjacency, path, onPath, cycles);
+ }
+
+ return cycles;
+ }
+
+ private static void Walk(
+ string start,
+ string current,
+ IReadOnlyDictionary> adjacency,
+ List path,
+ HashSet onPath,
+ List> cycles)
+ {
+ if (!adjacency.TryGetValue(current, out var neighbors))
+ {
+ return;
+ }
+
+ foreach (var next in neighbors)
+ {
+ if (next == start)
+ {
+ cycles.Add([.. path]);
+ continue;
+ }
+
+ if (string.CompareOrdinal(next, start) < 0 || onPath.Contains(next))
+ {
+ continue;
+ }
+
+ path.Add(next);
+ onPath.Add(next);
+
+ Walk(start, next, adjacency, path, onPath, cycles);
+
+ path.RemoveAt(path.Count - 1);
+ onPath.Remove(next);
+ }
+ }
+
+ private static string FormatCycle(IReadOnlyList cycle)
+ => string.Join(" -> ", cycle) + " -> " + cycle[0];
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs
new file mode 100644
index 00000000000..47bf0b581de
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs
@@ -0,0 +1,55 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class DeferTaskCommand : Command
+{
+ public DeferTaskCommand() : base("defer")
+ {
+ Description = "Defer a task until a future date.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks defer \"acme-1a2\" --until \"2026-02-01\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+ var untilValue = parseResult.GetRequiredValue(Opt.Instance);
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+
+ var deferUntil = TaskDates.Parse(untilValue, Opt.Instance.Name);
+
+ var task = await store.DeferTaskAsync(id, deferUntil, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task)));
+ return ExitCodes.Success;
+ }
+
+ console.OkLine(
+ $"Deferred task '{task.Id.EscapeMarkup()}' until {TaskDates.Format(deferUntil).EscapeMarkup()}.");
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs
new file mode 100644
index 00000000000..c3a9d3de273
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs
@@ -0,0 +1,79 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class DeleteTaskCommand : Command
+{
+ public DeleteTaskCommand() : base("delete")
+ {
+ Description = "Delete a task.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples(
+ "agent tasks delete \"app-1a2\"",
+ "agent tasks delete \"app-1a2\" --force");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var environmentVariableProvider = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+ var reason = parseResult.GetValue(Opt.Instance) ?? "";
+ var actor = TaskActor.Resolve(
+ parseResult.GetValue(Opt.Instance), environmentVariableProvider);
+ var force = parseResult.GetValue(Opt.Instance);
+
+ // Existence is checked up front, before the confirmation prompt, so a
+ // nonexistent task fails immediately instead of asking to confirm it.
+ await store.GetRequiredTaskAsync(id, cancellationToken);
+
+ if (!force)
+ {
+ if (console.IsInteractive)
+ {
+ var confirmed = await console.ConfirmAsync(
+ $"Delete task '{id.EscapeMarkup()}'?", cancellationToken);
+
+ if (!confirmed)
+ {
+ console.WriteLine("Aborted.");
+ return ExitCodes.Success;
+ }
+ }
+ else
+ {
+ throw new ExitException("Use --force to delete without confirmation.");
+ }
+ }
+
+ var task = await store.DeleteTaskAsync(id, reason, actor, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task)));
+ return ExitCodes.Success;
+ }
+
+ console.OkLine($"Deleted task '{task.Id.EscapeMarkup()}'.");
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs
new file mode 100644
index 00000000000..d91a1a67d41
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs
@@ -0,0 +1,159 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class DoctorTaskCommand : Command
+{
+ public DoctorTaskCommand() : base("doctor")
+ {
+ Description = "Check the task workspace for integrity problems.";
+
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks doctor");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var workspaceDirectory = store.FindWorkspaceDirectory()
+ ?? throw new ExitException(
+ "No task workspace found. Run `nitro agent tasks init` first.");
+
+ var prefix = await store.GetPrefixAsync(cancellationToken);
+ var taskCount = await store.CountTasksAsync(cancellationToken);
+ var integrity = await store.CheckIntegrityAsync(cancellationToken);
+ var cycles = await FindBlockingCyclesAsync(store, cancellationToken);
+
+ var healthy = integrity.QuickCheckOk
+ && cycles.Count == 0
+ && integrity.OrphanDependencies.Count == 0
+ && integrity.OrphanLabels.Count == 0
+ && integrity.OrphanComments.Count == 0
+ && integrity.TombstonedParentEdges.Count == 0;
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskDoctorResult(
+ workspaceDirectory,
+ prefix,
+ taskCount,
+ integrity.QuickCheckOk,
+ integrity.QuickCheckMessage,
+ cycles.Select(cycle => new TaskCycleResult(cycle)).ToArray(),
+ integrity.OrphanDependencies,
+ integrity.OrphanLabels,
+ integrity.OrphanComments,
+ integrity.TombstonedParentEdges,
+ healthy)));
+
+ return healthy ? ExitCodes.Success : ExitCodes.Error;
+ }
+
+ console.WriteLine($"Workspace: {workspaceDirectory}");
+ console.WriteLine($"Prefix: {prefix}");
+ console.WriteLine($"Tasks: {taskCount}");
+ console.WriteLine();
+
+ WriteCheck(
+ console,
+ "SQLite quick_check",
+ integrity.QuickCheckOk,
+ [integrity.QuickCheckMessage]);
+
+ WriteCheck(
+ console,
+ "Dependency cycles",
+ cycles.Count == 0,
+ cycles.Select(cycle => string.Join(" -> ", cycle) + " -> " + cycle[0]));
+
+ WriteCheck(
+ console,
+ "Orphan dependencies",
+ integrity.OrphanDependencies.Count == 0,
+ integrity.OrphanDependencies.Select(edge => $"{edge.TaskId} -> {edge.DependsOnId}"));
+
+ WriteCheck(
+ console,
+ "Orphan labels",
+ integrity.OrphanLabels.Count == 0,
+ integrity.OrphanLabels.Select(label => $"{label.TaskId}: '{label.Label}'"));
+
+ WriteCheck(
+ console,
+ "Orphan comments",
+ integrity.OrphanComments.Count == 0,
+ integrity.OrphanComments.Select(comment => $"{comment.TaskId}: comment #{comment.CommentId}"));
+
+ WriteCheck(
+ console,
+ "Tombstoned parent edges",
+ integrity.TombstonedParentEdges.Count == 0,
+ integrity.TombstonedParentEdges.Select(edge => $"{edge.TaskId} -> {edge.DependsOnId}"));
+
+ return healthy ? ExitCodes.Success : ExitCodes.Error;
+ }
+
+ private static void WriteCheck(
+ INitroConsole console,
+ string name,
+ bool ok,
+ IEnumerable problems)
+ {
+ if (ok)
+ {
+ console.OkLine(name);
+ return;
+ }
+
+ console.WriteLine($"FAIL {name}:");
+
+ foreach (var problem in problems)
+ {
+ console.WriteLine($" {problem}");
+ }
+ }
+
+ private static async Task>> FindBlockingCyclesAsync(
+ ITaskStore store,
+ CancellationToken cancellationToken)
+ {
+ var dependencyEdges = await store.GetDependencyEdgesAsync(cancellationToken);
+ var edges = dependencyEdges.Where(edge => TaskDependencyTypes.IsBlocking(edge.Type)).ToList();
+
+ var adjacency = edges
+ .GroupBy(edge => edge.TaskId)
+ .ToDictionary(
+ group => group.Key,
+ group => group.Select(edge => edge.DependsOnId)
+ .Distinct()
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList());
+
+ return CyclesTaskDependencyCommand.FindCycles(adjacency);
+ }
+
+ public sealed record TaskDoctorResult(
+ string WorkspacePath,
+ string Prefix,
+ int TaskCount,
+ bool QuickCheckOk,
+ string QuickCheckMessage,
+ IReadOnlyList Cycles,
+ IReadOnlyList OrphanDependencies,
+ IReadOnlyList OrphanLabels,
+ IReadOnlyList OrphanComments,
+ IReadOnlyList TombstonedParentEdges,
+ bool Healthy);
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs
new file mode 100644
index 00000000000..938615cf4b3
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs
@@ -0,0 +1,49 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class GetTaskConfigCommand : Command
+{
+ public GetTaskConfigCommand() : base("get")
+ {
+ Description = "Get a configuration value.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks config get prefix");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var key = parseResult.GetRequiredValue(Opt.Instance);
+
+ var value = await store.GetConfigAsync(key, cancellationToken)
+ ?? throw new ExitException($"Configuration key '{key}' is not set.");
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskConfigValueResult(value)));
+ return ExitCodes.Success;
+ }
+
+ console.WriteLine(value);
+
+ return ExitCodes.Success;
+ }
+
+ public sealed record TaskConfigValueResult(string Value);
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs
new file mode 100644
index 00000000000..0c37def607f
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs
@@ -0,0 +1,82 @@
+using System.Text;
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class InitTaskCommand : Command
+{
+ public InitTaskCommand() : base("init")
+ {
+ Description = "Initialize a task workspace in the current directory.";
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks init", "agent tasks init --prefix \"app\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var fileSystem = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var currentDirectory = fileSystem.GetCurrentDirectory();
+ var workspaceDirectory = TaskWorkspace.GetDirectory(currentDirectory);
+ var databasePath = TaskWorkspace.GetDatabasePath(workspaceDirectory);
+ var force = parseResult.GetValue(Opt.Instance);
+
+ if (!force && fileSystem.FileExists(databasePath))
+ {
+ throw new ExitException(
+ $"Already initialized at '{TaskWorkspace.DisplayPath}'. "
+ + "Use --force to reinitialize.");
+ }
+
+ var prefix = TaskWorkspace.NormalizePrefix(
+ parseResult.GetValue(Opt.Instance)
+ ?? Path.GetFileName(Path.TrimEndingDirectorySeparator(currentDirectory)));
+
+ if (!fileSystem.DirectoryExists(workspaceDirectory))
+ {
+ fileSystem.CreateDirectory(workspaceDirectory);
+ }
+
+ await store.InitializeWorkspaceAsync(workspaceDirectory, prefix, cancellationToken);
+
+ var gitIgnorePath = Path.Combine(
+ workspaceDirectory, TaskWorkspace.GitIgnoreFileName);
+
+ if (force || !fileSystem.FileExists(gitIgnorePath))
+ {
+ await using var gitIgnoreStream = fileSystem.CreateFile(gitIgnorePath);
+ await gitIgnoreStream.WriteAsync(
+ Encoding.UTF8.GetBytes(TaskWorkspace.GitIgnoreContent),
+ cancellationToken);
+ }
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskWorkspaceInitResult(workspaceDirectory, prefix)));
+ return ExitCodes.Success;
+ }
+
+ console.OkLine($"Initialized task workspace at '{TaskWorkspace.DisplayPath}'.");
+ console.OkLine($"Task ID prefix set to '{prefix}'.");
+
+ return ExitCodes.Success;
+ }
+
+ public sealed record TaskWorkspaceInitResult(string Path, string Prefix);
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs
new file mode 100644
index 00000000000..7baccbe9ab9
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs
@@ -0,0 +1,107 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class LintTaskCommand : Command
+{
+ public LintTaskCommand() : base("lint")
+ {
+ Description = "Report task quality findings.";
+
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks lint");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var findings = await CollectFindingsAsync(store, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(findings));
+ return ExitCodes.Success;
+ }
+
+ if (findings.Count == 0)
+ {
+ console.WriteLine("No lint findings.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var finding in findings)
+ {
+ console.WriteLine($"{finding.TaskId} {finding.Rule}: {finding.Message}");
+ }
+
+ return ExitCodes.Success;
+ }
+
+ private static async Task> CollectFindingsAsync(
+ ITaskStore store,
+ CancellationToken cancellationToken)
+ {
+ var findings = new List();
+
+ var tasks = await store.QueryTasksAsync(
+ new TaskFilter { IncludeAll = true, ExcludeTombstones = true },
+ cancellationToken);
+
+ foreach (var task in tasks)
+ {
+ if (task.Status == TaskStates.Open && string.IsNullOrWhiteSpace(task.Description))
+ {
+ findings.Add(new TaskLintFinding(
+ task.Id, "empty-description", "Open task has an empty description."));
+ }
+
+ if (task.Status == TaskStates.Deferred && task.DeferUntil is null)
+ {
+ findings.Add(new TaskLintFinding(
+ task.Id, "deferred-without-defer-until", "Deferred task has no defer_until."));
+ }
+
+ if (task.Status == TaskStates.InProgress && string.IsNullOrEmpty(task.Assignee))
+ {
+ findings.Add(new TaskLintFinding(
+ task.Id, "in-progress-without-assignee", "In-progress task has no assignee."));
+ }
+ }
+
+ var epicStatuses = await store.GetEpicStatusesAsync(cancellationToken);
+
+ foreach (var epic in epicStatuses)
+ {
+ if (epic.Total == 0)
+ {
+ findings.Add(new TaskLintFinding(
+ epic.Id, "epic-no-children", "Epic has zero children."));
+ }
+ else if (epic.Status == TaskStates.Closed && epic.Closed < epic.Total)
+ {
+ findings.Add(new TaskLintFinding(
+ epic.Id, "closed-epic-open-children", "Closed epic has open children."));
+ }
+ }
+
+ findings.Sort((left, right) =>
+ {
+ var byTaskId = string.CompareOrdinal(left.TaskId, right.TaskId);
+ return byTaskId != 0 ? byTaskId : string.CompareOrdinal(left.Rule, right.Rule);
+ });
+
+ return findings;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs
new file mode 100644
index 00000000000..d17563b0d76
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs
@@ -0,0 +1,100 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class ListTaskCommand : Command
+{
+ public ListTaskCommand() : base("list")
+ {
+ Description = "List tasks.";
+
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples(
+ "agent tasks list",
+ "agent tasks list --status open --status in_progress",
+ "agent tasks list --assignee alice --priority p1");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var statuses = parseResult.GetValue(Opt.Instance);
+ var type = parseResult.GetValue(Opt.Instance);
+ var priority = parseResult.GetValue(Opt.Instance);
+ var assignee = parseResult.GetValue(Opt.Instance);
+ var labels = parseResult.GetValue(Opt.Instance);
+ var limit = parseResult.GetValue(Opt.Instance);
+ var all = parseResult.GetValue(Opt.Instance);
+
+ var priorityRange = priority is null ? default((int Min, int Max)?) : TaskPriorities.ParseRange(priority);
+
+ var filter = new TaskFilter
+ {
+ Statuses = statuses,
+ IncludeAll = all,
+ Type = type,
+ PriorityMin = priorityRange?.Min,
+ PriorityMax = priorityRange?.Max,
+ Assignee = assignee,
+ Labels = labels,
+ Limit = limit
+ };
+
+ var tasks = await store.QueryTasksAsync(filter, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(tasks.Select(ToSummary).ToArray()));
+ return ExitCodes.Success;
+ }
+
+ if (tasks.Count == 0)
+ {
+ console.WriteLine("No tasks found.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var task in tasks)
+ {
+ console.WriteLine(FormatRow(task));
+ }
+
+ console.WriteLine();
+ console.WriteLine($"{tasks.Count} task(s)");
+
+ return ExitCodes.Success;
+ }
+
+ private static string FormatRow(TaskItem task)
+ => new TaskListRow
+ {
+ Id = task.Id,
+ Priority = task.Priority,
+ Type = task.Type,
+ Status = task.Status,
+ Title = task.Title
+ }.Format();
+
+ private static TaskSummaryResult ToSummary(TaskItem task)
+ => new(task.Id, task.Priority, task.Type, task.Status, task.Title);
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs
new file mode 100644
index 00000000000..61c685181c7
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs
@@ -0,0 +1,68 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class ListTaskCommentCommand : Command
+{
+ public ListTaskCommentCommand() : base("list")
+ {
+ Description = "List a task's comments.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks comment list \"acme-1a2\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+
+ var task = await store.GetRequiredTaskAsync(id, cancellationToken);
+ var comments = await store.GetCommentsAsync(task.Id, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(comments));
+ return ExitCodes.Success;
+ }
+
+ if (comments.Count == 0)
+ {
+ console.WriteLine("No comments.");
+ return ExitCodes.Success;
+ }
+
+ for (var i = 0; i < comments.Count; i++)
+ {
+ if (i > 0)
+ {
+ console.WriteLine();
+ }
+
+ var comment = comments[i];
+
+ console.WriteLine($" [{i + 1}] {comment.Author} {TaskDates.Format(comment.CreatedAt)}");
+
+ foreach (var line in comment.Text.ReplaceLineEndings("\n").Split('\n'))
+ {
+ console.WriteLine(" " + line);
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs
new file mode 100644
index 00000000000..c97afb26889
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs
@@ -0,0 +1,45 @@
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class ListTaskConfigCommand : Command
+{
+ public ListTaskConfigCommand() : base("list")
+ {
+ Description = "List all configuration values.";
+
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks config list");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var rows = await store.ListConfigAsync(cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(rows));
+ return ExitCodes.Success;
+ }
+
+ foreach (var row in rows)
+ {
+ console.WriteLine($"{row.Key} = {row.Value}");
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs
new file mode 100644
index 00000000000..017d8400a23
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs
@@ -0,0 +1,98 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class ListTaskDependencyCommand : Command
+{
+ public ListTaskDependencyCommand() : base("list")
+ {
+ Description = "List a task's dependencies.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks dep list \"acme-1a2\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetRequiredValue(Opt.Instance);
+
+ var task = await store.GetRequiredTaskAsync(id, cancellationToken);
+ var dependencies = await store.GetDependenciesAsync(task.Id, cancellationToken);
+ var dependents = await store.GetDependentsAsync(task.Id, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ObjectResult(new TaskDependenciesResult(dependencies, dependents)));
+ return ExitCodes.Success;
+ }
+
+ if (dependencies.Count == 0 && dependents.Count == 0)
+ {
+ console.WriteLine("No dependencies.");
+ return ExitCodes.Success;
+ }
+
+ if (dependencies.Count > 0)
+ {
+ console.WriteLine($"Dependencies of {task.Id}:");
+
+ foreach (var dependency in dependencies)
+ {
+ console.WriteLine(FormatDependency(dependency));
+ }
+ }
+
+ if (dependents.Count > 0)
+ {
+ console.WriteLine("Depended on by:");
+
+ foreach (var dependent in dependents)
+ {
+ console.WriteLine(FormatDependent(dependent));
+ }
+ }
+
+ return ExitCodes.Success;
+ }
+
+ private static string FormatDependency(TaskDependencyDetail dependency)
+ {
+ var status = dependency.Status ?? "unknown";
+ var line = $" {dependency.Type} -> {dependency.DependsOnId} ({status})";
+
+ if (!string.IsNullOrEmpty(dependency.Title))
+ {
+ line += $" {dependency.Title}";
+ }
+
+ return line;
+ }
+
+ private static string FormatDependent(TaskDependentDetail dependent)
+ {
+ var status = dependent.Status ?? "unknown";
+ var line = $" {dependent.TaskId} ({dependent.Type}, {status})";
+
+ if (!string.IsNullOrEmpty(dependent.Title))
+ {
+ line += $" {dependent.Title}";
+ }
+
+ return line;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs
new file mode 100644
index 00000000000..bb889f9f92e
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs
@@ -0,0 +1,81 @@
+using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+using ChilliCream.Nitro.CommandLine.Helpers;
+using ChilliCream.Nitro.CommandLine.Results;
+using ChilliCream.Nitro.CommandLine.Services;
+using ChilliCream.Nitro.CommandLine.Services.Tasks;
+
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks;
+
+internal sealed class ListTaskLabelCommand : Command
+{
+ public ListTaskLabelCommand() : base("list")
+ {
+ Description = "List a task's labels, or every label in use.";
+
+ Arguments.Add(Opt.Instance);
+ Options.Add(Opt.Instance);
+
+ this.AddExamples("agent tasks label list", "agent tasks label list \"acme-1a2\"");
+
+ this.SetActionWithExceptionHandling(ExecuteAsync);
+ }
+
+ private static async Task ExecuteAsync(
+ ICommandServices services,
+ ParseResult parseResult,
+ CancellationToken cancellationToken)
+ {
+ var console = services.GetRequiredService();
+ var store = services.GetRequiredService();
+ var resultHolder = services.GetRequiredService();
+
+ var id = parseResult.GetValue(Opt.Instance);
+
+ if (id is not null)
+ {
+ var task = await store.GetRequiredTaskAsync(id, cancellationToken);
+ var labels = await store.GetLabelsAsync(task.Id, cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ var items = labels.Select(label => new TaskLabelCount(label, 1)).ToList();
+ resultHolder.SetResult(new ListResult(items));
+ return ExitCodes.Success;
+ }
+
+ if (labels.Count == 0)
+ {
+ console.WriteLine("No labels.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var label in labels)
+ {
+ console.WriteLine(label);
+ }
+
+ return ExitCodes.Success;
+ }
+
+ var rows = await store.GetLabelCountsAsync(cancellationToken);
+
+ if (!console.IsHumanReadable)
+ {
+ resultHolder.SetResult(new ListResult(rows));
+ return ExitCodes.Success;
+ }
+
+ if (rows.Count == 0)
+ {
+ console.WriteLine("No labels.");
+ return ExitCodes.Success;
+ }
+
+ foreach (var row in rows)
+ {
+ console.WriteLine($"{row.Label} {row.Count}");
+ }
+
+ return ExitCodes.Success;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs
new file mode 100644
index 00000000000..a9ec7af3801
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs
@@ -0,0 +1,9 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class CommentTextArgument : Argument
+{
+ public CommentTextArgument() : base("text")
+ {
+ Description = "The comment text";
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs
new file mode 100644
index 00000000000..a8983c28d50
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs
@@ -0,0 +1,9 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class ConfigKeyArgument : Argument
+{
+ public ConfigKeyArgument() : base("key")
+ {
+ Description = "The configuration key";
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs
new file mode 100644
index 00000000000..3c612c937fc
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs
@@ -0,0 +1,9 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class ConfigValueArgument : Argument
+{
+ public ConfigValueArgument() : base("value")
+ {
+ Description = "The configuration value";
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs
new file mode 100644
index 00000000000..179be0ad0ec
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs
@@ -0,0 +1,9 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class DependsOnIdArgument : Argument
+{
+ public DependsOnIdArgument() : base("depends-on-id")
+ {
+ Description = "The task this dependency points to";
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs
new file mode 100644
index 00000000000..eae6207722f
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs
@@ -0,0 +1,10 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class ForceReinitializeOption : Option
+{
+ public ForceReinitializeOption() : base("--force")
+ {
+ Description = "Reinitialize an existing task workspace";
+ Required = false;
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs
new file mode 100644
index 00000000000..d3b84d80fc4
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs
@@ -0,0 +1,9 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class LabelArgument : Argument
+{
+ public LabelArgument() : base("label")
+ {
+ Description = "The label";
+ }
+}
diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs
new file mode 100644
index 00000000000..6f7cc1eedb5
--- /dev/null
+++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs
@@ -0,0 +1,10 @@
+namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options;
+
+internal sealed class LabelsArgument : Argument