diff --git a/src/Polyphony/Commands/StatusCommand.cs b/src/Polyphony/Commands/StatusCommand.cs
new file mode 100644
index 00000000..b3312beb
--- /dev/null
+++ b/src/Polyphony/Commands/StatusCommand.cs
@@ -0,0 +1,384 @@
+using System.Reflection;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using ConsoleAppFramework;
+using Polyphony.Annotations;
+using Polyphony.Infrastructure.Processes;
+using Polyphony.Manifest;
+using Polyphony.Tagging;
+using Twig.Domain.Interfaces;
+
+namespace Polyphony.Commands;
+
+///
+/// polyphony status — periodic dashboard snapshot for a single apex.
+/// Composes the ADO cache, the run manifest, and a best-effort gh PR query
+/// into a unified JSON envelope plus a human-readable headline. Designed
+/// for polling (e.g. a dashboard widget after each conductor event).
+///
+/// Routing-style verb: ALWAYS exits 0. Failure modes (work item
+/// missing, manifest unparseable, gh hung) are surfaced through per-section
+/// error fields and the array,
+/// not via process exit code. The dashboard cares about cross-signal
+/// coherence — a wedged gh shouldn't blank the whole report.
+///
+/// Cross-signal warnings caught today:
+///
+/// - apex_not_in_scope — work item missing the
+/// / tag.
+/// - apex_not_root — work item in-scope but missing
+/// ; status was likely invoked on a
+/// descendant by mistake.
+/// - planned_tag_zero_children — apex carries
+/// but has zero ADO children.
+/// This is the AB#3064 false-satisfied dogfood bug — the seeder
+/// stamped the tag with empty input. Now caught at lint time by
+/// PR #225's strict seed-children, but the warning stays as a
+/// belt-and-braces dashboard signal.
+/// - manifest_missing — no .polyphony/run.yaml; either
+/// the run hasn't started or the working directory isn't the run root.
+/// - feature_pr_unmerged_progress — manifest records merged
+/// plan PRs but no feature PR exists or it's still open. Honest
+/// signal that work has happened but hasn't been promoted.
+///
+///
+[VerbGroup("")]
+public sealed class StatusCommand(
+ IWorkItemRepository repository,
+ IGitClient git,
+ IGhClient gh)
+{
+ private static readonly Regex GitHubSlugRegex =
+ new(@"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?(?:[/?#].*)?$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// Compose a periodic status snapshot for an apex work item.
+ /// Apex (focus) work item ID.
+ /// Owner/repo slug for the gh feature-PR lookup.
+ /// When empty, derived from the origin remote; when no slug can
+ /// be derived, the feature_pr section reports error: no_slug.
+ /// Run manifest path. Defaults to .polyphony/run.yaml.
+ /// Cancellation token.
+ [Command("status")]
+ [VerbResult(typeof(StatusResult))]
+ public async Task Status(
+ int apex = RequiredInput.MissingInt,
+ string repoSlug = "",
+ string manifestPath = RunManifestStore.DefaultRelativePath,
+ CancellationToken ct = default)
+ {
+ if (RequiredInput.HaltIfMissing("status",
+ ("--apex", apex == RequiredInput.MissingInt)) is { } halt)
+ return halt;
+
+ var ado = await ReadAdoSectionAsync(apex, ct).ConfigureAwait(false);
+ var manifest = ReadManifestSection(manifestPath);
+ var featurePr = await ReadFeaturePrSectionAsync(apex, repoSlug, ct).ConfigureAwait(false);
+ var binary = ReadBinarySection();
+
+ var warnings = ComputeWarnings(ado, manifest, featurePr);
+ var (headline, nextAction) = ComputeHeadline(apex, ado, manifest, featurePr, warnings);
+
+ var result = new StatusResult
+ {
+ ApexId = apex,
+ Ado = ado,
+ Manifest = manifest,
+ FeaturePr = featurePr,
+ Binary = binary,
+ Warnings = warnings,
+ Headline = headline,
+ NextAction = nextAction,
+ };
+
+ Console.WriteLine(JsonSerializer.Serialize(
+ result, PolyphonyJsonContext.Default.StatusResult));
+ return ExitCodes.Success;
+ }
+
+ private async Task ReadAdoSectionAsync(int apex, CancellationToken ct)
+ {
+ var item = await repository.GetByIdAsync(apex, ct).ConfigureAwait(false);
+ if (item is null)
+ {
+ return new StatusAdoSection
+ {
+ Found = false,
+ Tags = [],
+ InScope = false,
+ IsRoot = false,
+ HasPlannedTag = false,
+ ChildrenCount = 0,
+ Error = $"Work item {apex} not found in twig cache",
+ };
+ }
+
+ item.Fields.TryGetValue("System.Tags", out var rawTags);
+ var tags = TagSet.Parse(rawTags);
+
+ int childrenCount;
+ try
+ {
+ var children = await repository.GetChildrenAsync(apex, ct).ConfigureAwait(false);
+ childrenCount = children.Count;
+ }
+ catch
+ {
+ childrenCount = 0;
+ }
+
+ return new StatusAdoSection
+ {
+ Found = true,
+ Type = item.Type.Value,
+ State = item.State,
+ Title = item.Title,
+ Tags = tags.ToArray(),
+ InScope = PolyphonyTags.IsInScope(tags),
+ IsRoot = PolyphonyTags.IsRoot(tags),
+ HasPlannedTag = tags.Contains(PolyphonyTags.Planned),
+ ChildrenCount = childrenCount,
+ };
+ }
+
+ private static StatusManifestSection ReadManifestSection(string manifestPath)
+ {
+ if (!File.Exists(manifestPath))
+ {
+ return new StatusManifestSection
+ {
+ Exists = false,
+ Path = manifestPath,
+ };
+ }
+
+ try
+ {
+ var manifest = RunManifestStore.LoadOrThrow(manifestPath);
+ int? rootGen = manifest.PlanGenerations is { Count: > 0 } pg
+ && pg.TryGetValue("root", out var v) ? v : null;
+ return new StatusManifestSection
+ {
+ Exists = true,
+ Path = manifestPath,
+ FeatureBranch = $"feature/{manifest.RootId}",
+ PlanGenerationsRoot = rootGen,
+ MergedPlanPrsCount = manifest.MergedPlanPrs?.Count ?? 0,
+ MergeGroupsCount = manifest.MergeGroups?.Count ?? 0,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new StatusManifestSection
+ {
+ Exists = true,
+ Path = manifestPath,
+ Error = ex.Message,
+ };
+ }
+ }
+
+ private async Task ReadFeaturePrSectionAsync(
+ int apex, string repoSlug, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(repoSlug))
+ {
+ try
+ {
+ var url = await git.GetRemoteUrlAsync("origin", ct).ConfigureAwait(false);
+ if (!string.IsNullOrEmpty(url))
+ {
+ var match = GitHubSlugRegex.Match(url);
+ if (match.Success) repoSlug = match.Groups[1].Value;
+ }
+ }
+ catch
+ {
+ // Fall through — no slug → no PR lookup.
+ }
+ }
+
+ if (string.IsNullOrEmpty(repoSlug))
+ {
+ return new StatusFeaturePrSection
+ {
+ Exists = false,
+ Error = "no_slug",
+ };
+ }
+
+ var headBranch = $"feature/{apex}";
+ try
+ {
+ // Look at all states (open/merged/closed) so a merged feature PR is
+ // visible. gh's --state default is `open`, which would hide a merged
+ // PR and trip the feature_pr_unmerged_progress warning incorrectly.
+ var prs = await gh.ListPullRequestsAsync(
+ repoSlug,
+ new PrListFilters(Head: headBranch, State: "all", Limit: 5),
+ ct).ConfigureAwait(false);
+
+ var match = prs.FirstOrDefault(p => string.Equals(
+ p.HeadRefName, headBranch, StringComparison.Ordinal));
+ if (match is null)
+ {
+ return new StatusFeaturePrSection { Exists = false };
+ }
+
+ var state = match.MergedAt.HasValue ? "MERGED" : "OPEN";
+ return new StatusFeaturePrSection
+ {
+ Exists = true,
+ Number = match.Number,
+ Url = match.Url,
+ State = state,
+ MergedAt = match.MergedAt?.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new StatusFeaturePrSection
+ {
+ Exists = false,
+ Error = ex.Message,
+ };
+ }
+ }
+
+ private static StatusBinarySection ReadBinarySection()
+ {
+ var asm = typeof(StatusCommand).Assembly;
+ var informational = asm.GetCustomAttribute()?.InformationalVersion
+ ?? "unknown";
+ var version = asm.GetName().Version?.ToString() ?? "unknown";
+ string? location = null;
+ try
+ {
+ location = Environment.ProcessPath;
+ }
+ catch
+ {
+ // Best-effort; never poison the result.
+ }
+
+ return new StatusBinarySection
+ {
+ Version = version,
+ InformationalVersion = informational,
+ Location = location,
+ };
+ }
+
+ private static IReadOnlyList ComputeWarnings(
+ StatusAdoSection ado,
+ StatusManifestSection manifest,
+ StatusFeaturePrSection featurePr)
+ {
+ var warnings = new List();
+
+ if (ado.Found)
+ {
+ if (!ado.InScope)
+ {
+ warnings.Add(new StatusWarning
+ {
+ Code = "apex_not_in_scope",
+ Message = "Work item is missing the polyphony / polyphony:root tag.",
+ });
+ }
+ else if (!ado.IsRoot)
+ {
+ warnings.Add(new StatusWarning
+ {
+ Code = "apex_not_root",
+ Message = "Work item carries the polyphony tag but not polyphony:root. status was probably invoked on a descendant.",
+ });
+ }
+
+ if (ado.HasPlannedTag && ado.ChildrenCount == 0)
+ {
+ warnings.Add(new StatusWarning
+ {
+ Code = "planned_tag_zero_children",
+ Message = "polyphony:planned tag is set but the work item has no ADO children. Closed-loop §3.4(a) requires apex_facets in the plan front-matter when the apex is genuinely indivisible; otherwise this is a false-satisfied bug.",
+ });
+ }
+ }
+
+ if (!manifest.Exists)
+ {
+ warnings.Add(new StatusWarning
+ {
+ Code = "manifest_missing",
+ Message = $"Run manifest not found at {manifest.Path ?? RunManifestStore.DefaultRelativePath}. Either the run has not started or the working directory is not the run root.",
+ });
+ }
+
+ if (manifest.Exists
+ && manifest.MergedPlanPrsCount is > 0
+ && (!featurePr.Exists || string.Equals(featurePr.State, "OPEN", StringComparison.Ordinal)))
+ {
+ warnings.Add(new StatusWarning
+ {
+ Code = "feature_pr_unmerged_progress",
+ Message = "Manifest records merged plan PRs but the feature PR is not yet merged. Implementation work has happened but is not promoted to main.",
+ });
+ }
+
+ return warnings;
+ }
+
+ private static (string Headline, string? NextAction) ComputeHeadline(
+ int apex,
+ StatusAdoSection ado,
+ StatusManifestSection manifest,
+ StatusFeaturePrSection featurePr,
+ IReadOnlyList warnings)
+ {
+ if (!ado.Found)
+ {
+ return ($"apex {apex}: not found in twig cache",
+ "Run `twig sync` to refresh the cache, or verify the work item ID.");
+ }
+
+ // Most-actionable warning takes the headline.
+ var plannedZero = warnings.FirstOrDefault(w => w.Code == "planned_tag_zero_children");
+ if (plannedZero is not null)
+ {
+ return ($"apex {apex}: planned but no children — false-satisfied bug",
+ $"Inspect the plan: `cat plans/plan-{apex}.md`. Re-run plan-level once F4 lint catches prose-only declarations.");
+ }
+
+ var notInScope = warnings.FirstOrDefault(w => w.Code == "apex_not_in_scope");
+ if (notInScope is not null)
+ {
+ return ($"apex {apex}: not in polyphony scope (tag missing)",
+ $"polyphony root declare --work-item {apex}");
+ }
+
+ var unmergedProgress = warnings.FirstOrDefault(w => w.Code == "feature_pr_unmerged_progress");
+ if (unmergedProgress is not null)
+ {
+ var prSegment = featurePr is { Exists: true, Number: { } n }
+ ? $"feature PR #{n} is open"
+ : "no feature PR exists";
+ return ($"apex {apex}: progress recorded ({manifest.MergedPlanPrsCount} plan PR(s) merged) but {prSegment}",
+ "Check `gh pr list --state open` for an open feature PR; ship it once impl PRs are merged.");
+ }
+
+ if (featurePr is { Exists: true, State: "MERGED" })
+ {
+ return ($"apex {apex}: feature PR #{featurePr.Number} merged",
+ "Verify ADO state has advanced (item_satisfied transition pending until F5 lands).");
+ }
+
+ if (!manifest.Exists)
+ {
+ return ($"apex {apex}: manifest not initialised (state={ado.State})",
+ $"conductor run apex-driver@polyphony --input apex_id={apex}");
+ }
+
+ return ($"apex {apex}: in flight (state={ado.State}, children={ado.ChildrenCount}, merged plan PRs={manifest.MergedPlanPrsCount ?? 0})",
+ "Run `polyphony state next-ready --work-item {apex}` for the next dispatchable requirement.");
+ }
+}
diff --git a/src/Polyphony/Models/StatusResult.cs b/src/Polyphony/Models/StatusResult.cs
new file mode 100644
index 00000000..d8b12f82
--- /dev/null
+++ b/src/Polyphony/Models/StatusResult.cs
@@ -0,0 +1,105 @@
+namespace Polyphony;
+
+///
+/// Aggregated dashboard snapshot for a single apex work item. Composed by
+/// polyphony status from the ADO cache, the run manifest, and a
+/// best-effort gh PR query. Always exit 0 (routing-style); failure modes
+/// are surfaced via the array and per-section
+/// error fields rather than via process exit code.
+///
+/// Designed for periodic polling (e.g. a dashboard widget). Cross-signal
+/// detections live in ; the
+/// + pair give a one-line human-readable summary.
+///
+public sealed record StatusResult
+{
+ public required int ApexId { get; init; }
+ public required StatusAdoSection Ado { get; init; }
+ public required StatusManifestSection Manifest { get; init; }
+ public required StatusFeaturePrSection FeaturePr { get; init; }
+ public required StatusBinarySection Binary { get; init; }
+ public required IReadOnlyList Warnings { get; init; }
+ public required string Headline { get; init; }
+ public string? NextAction { get; init; }
+}
+
+///
+/// ADO-side observable signals: type, state, title, raw tags, plus
+/// derived booleans for the polyphony tag namespace and the count of
+/// direct children. is populated when the lookup
+/// failed (e.g. work item not in cache); the rest of the section is
+/// best-effort empty in that case so consumers can still render.
+///
+public sealed record StatusAdoSection
+{
+ public required bool Found { get; init; }
+ public string? Type { get; init; }
+ public string? State { get; init; }
+ public string? Title { get; init; }
+ public required IReadOnlyList Tags { get; init; }
+ public required bool InScope { get; init; }
+ public required bool IsRoot { get; init; }
+ public required bool HasPlannedTag { get; init; }
+ public required int ChildrenCount { get; init; }
+ public string? Error { get; init; }
+}
+
+///
+/// Run-manifest snapshot. false means the file is
+/// absent (not yet initialised). When present, the rest of the section
+/// reflects the parsed manifest. is populated when
+/// the file existed but failed to parse.
+///
+public sealed record StatusManifestSection
+{
+ public required bool Exists { get; init; }
+ public string? Path { get; init; }
+ public string? FeatureBranch { get; init; }
+ public int? PlanGenerationsRoot { get; init; }
+ public int? MergedPlanPrsCount { get; init; }
+ public int? MergeGroupsCount { get; init; }
+ public string? Error { get; init; }
+}
+
+///
+/// Feature PR (head feature/{apex_id} → main) summary. The lookup
+/// is gh-best-effort: a missing PR yields false, a
+/// transient gh failure yields populated and the rest
+/// of the section empty.
+///
+public sealed record StatusFeaturePrSection
+{
+ public required bool Exists { get; init; }
+ public int? Number { get; init; }
+ public string? Url { get; init; }
+ public string? State { get; init; }
+ public string? MergedAt { get; init; }
+ public string? Error { get; init; }
+}
+
+///
+/// Self-reported polyphony binary metadata.
+/// is the SemVer-with-buildmetadata that MinVer writes;
+/// is the numeric AssemblyVersion (stable across pre-releases). Both are
+/// surfaced because operators frequently want to spot-check that a worktree
+/// is running the binary they think it is (the AB#3064 dogfood was bitten by
+/// a stale binary).
+///
+public sealed record StatusBinarySection
+{
+ public required string Version { get; init; }
+ public required string InformationalVersion { get; init; }
+ public string? Location { get; init; }
+}
+
+///
+/// Cross-signal detection. Codes are stable identifiers safe for routing
+/// (planned_tag_zero_children, apex_not_in_scope, etc.);
+/// is human-readable and may be surfaced verbatim
+/// in dashboards.
+///
+public sealed record StatusWarning
+{
+ public required string Code { get; init; }
+ public required string Message { get; init; }
+}
diff --git a/src/Polyphony/PolyphonyJsonContext.cs b/src/Polyphony/PolyphonyJsonContext.cs
index 33ccc563..d6ee28e6 100644
--- a/src/Polyphony/PolyphonyJsonContext.cs
+++ b/src/Polyphony/PolyphonyJsonContext.cs
@@ -172,6 +172,12 @@ namespace Polyphony;
[JsonSerializable(typeof(RequiredInputErrorResult))]
[JsonSerializable(typeof(StateValidateInputsResult))]
[JsonSerializable(typeof(StateValidateInputsDiagnostic))]
+[JsonSerializable(typeof(StatusResult))]
+[JsonSerializable(typeof(StatusAdoSection))]
+[JsonSerializable(typeof(StatusManifestSection))]
+[JsonSerializable(typeof(StatusFeaturePrSection))]
+[JsonSerializable(typeof(StatusBinarySection))]
+[JsonSerializable(typeof(StatusWarning))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
diff --git a/src/Polyphony/Program.cs b/src/Polyphony/Program.cs
index fa843239..361e9b42 100644
--- a/src/Polyphony/Program.cs
+++ b/src/Polyphony/Program.cs
@@ -25,6 +25,7 @@
app.Add();
app.Add();
app.Add();
+app.Add();
app.Add("plan");
app.Add("policy");
app.Add("guidance");
@@ -49,7 +50,7 @@
// app.Add<...>() calls above.
var knownVerbRoots = new HashSet(StringComparer.Ordinal)
{
- "validate", "validate-config", "hierarchy", "health",
+ "validate", "validate-config", "hierarchy", "health", "status",
"plan", "policy", "guidance", "branch", "state", "pr", "scope", "root",
"requirements", "mg", "manifest", "lock", "worktree", "worklist", "edges", "agent",
// Built-ins / pass-throughs handled by CAF itself.
diff --git a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs
index e269df81..44d60d9a 100644
--- a/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs
+++ b/tests/Polyphony.Tests/Commands/JsonOutputContractTests.cs
@@ -1767,5 +1767,190 @@ public void PrGetCommentsAdoResult_OptionalFileLine_NullSuppressed()
json.ShouldNotContain("\"published_at\"");
json.ShouldNotContain("\"last_updated_at\"");
}
+
+ // =========================================================================
+ // Status command — JSON contract
+ // (Routing-style: ALWAYS exits 0; no NotFound CacheError shape.)
+ // =========================================================================
+
+ [Fact]
+ public void Status_SnakeCaseFieldNames_PresentInRawJson()
+ {
+ var result = new StatusResult
+ {
+ ApexId = 42,
+ Ado = new StatusAdoSection
+ {
+ Found = true,
+ Type = "Issue",
+ State = "Doing",
+ Title = "Sample",
+ Tags = ["polyphony", "polyphony:root"],
+ InScope = true,
+ IsRoot = true,
+ HasPlannedTag = false,
+ ChildrenCount = 3,
+ },
+ Manifest = new StatusManifestSection
+ {
+ Exists = true,
+ Path = ".polyphony/run.yaml",
+ FeatureBranch = "feature/42",
+ PlanGenerationsRoot = 1,
+ MergedPlanPrsCount = 1,
+ MergeGroupsCount = 0,
+ },
+ FeaturePr = new StatusFeaturePrSection { Exists = false },
+ Binary = new StatusBinarySection
+ {
+ Version = "1.0.0.0",
+ InformationalVersion = "1.2.3-alpha.0.4",
+ },
+ Warnings = [],
+ Headline = "ok",
+ };
+
+ var json = JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.StatusResult);
+
+ // Top-level snake_case keys.
+ json.ShouldContain("\"apex_id\"");
+ json.ShouldContain("\"ado\"");
+ json.ShouldContain("\"manifest\"");
+ json.ShouldContain("\"feature_pr\"");
+ json.ShouldContain("\"binary\"");
+ json.ShouldContain("\"warnings\"");
+ json.ShouldContain("\"headline\"");
+ // Per-section snake_case keys.
+ json.ShouldContain("\"has_planned_tag\"");
+ json.ShouldContain("\"is_root\"");
+ json.ShouldContain("\"in_scope\"");
+ json.ShouldContain("\"children_count\"");
+ json.ShouldContain("\"feature_branch\"");
+ json.ShouldContain("\"plan_generations_root\"");
+ json.ShouldContain("\"merged_plan_prs_count\"");
+ json.ShouldContain("\"merge_groups_count\"");
+ json.ShouldContain("\"informational_version\"");
+
+ AssertNoPascalCase(json, "ApexId");
+ AssertNoPascalCase(json, "HasPlannedTag");
+ AssertNoPascalCase(json, "IsRoot");
+ AssertNoPascalCase(json, "InScope");
+ AssertNoPascalCase(json, "ChildrenCount");
+ AssertNoPascalCase(json, "FeaturePr");
+ AssertNoPascalCase(json, "FeatureBranch");
+ AssertNoPascalCase(json, "PlanGenerationsRoot");
+ AssertNoPascalCase(json, "MergedPlanPrsCount");
+ AssertNoPascalCase(json, "MergeGroupsCount");
+ AssertNoPascalCase(json, "InformationalVersion");
+ }
+
+ [Fact]
+ public void Status_NullFieldsOmitted_WhenWritingNull()
+ {
+ var result = new StatusResult
+ {
+ ApexId = 42,
+ Ado = new StatusAdoSection
+ {
+ Found = false,
+ Tags = [],
+ InScope = false,
+ IsRoot = false,
+ HasPlannedTag = false,
+ ChildrenCount = 0,
+ Error = "missing",
+ },
+ Manifest = new StatusManifestSection { Exists = false },
+ FeaturePr = new StatusFeaturePrSection { Exists = false },
+ Binary = new StatusBinarySection
+ {
+ Version = "1.0.0.0",
+ InformationalVersion = "1.2.3",
+ Location = null,
+ },
+ Warnings = [],
+ Headline = "missing",
+ NextAction = null,
+ };
+
+ var json = JsonSerializer.Serialize(result, PolyphonyJsonContext.Default.StatusResult);
+
+ // Optional/null fields must not appear in the wire output.
+ json.ShouldNotContain("\"next_action\"");
+ json.ShouldNotContain("\"location\"");
+ json.ShouldNotContain("\"type\":");
+ json.ShouldNotContain("\"state\":");
+ json.ShouldNotContain("\"title\":");
+ json.ShouldNotContain("\"feature_branch\"");
+ json.ShouldNotContain("\"plan_generations_root\"");
+ json.ShouldNotContain("\"merged_plan_prs_count\"");
+ json.ShouldNotContain("\"merge_groups_count\"");
+ json.ShouldNotContain("\"path\":");
+ json.ShouldNotContain("\"number\":");
+ json.ShouldNotContain("\"url\":");
+ json.ShouldNotContain("\"merged_at\":");
+ }
+
+ [Fact]
+ public void Status_DeserializationRoundTrip_FieldsMapped()
+ {
+ var original = new StatusResult
+ {
+ ApexId = 7,
+ Ado = new StatusAdoSection
+ {
+ Found = true,
+ Type = "Issue",
+ State = "Doing",
+ Title = "Roundtrip",
+ Tags = ["polyphony", "polyphony:root", "polyphony:planned"],
+ InScope = true,
+ IsRoot = true,
+ HasPlannedTag = true,
+ ChildrenCount = 0,
+ },
+ Manifest = new StatusManifestSection
+ {
+ Exists = true,
+ Path = ".polyphony/run.yaml",
+ FeatureBranch = "feature/7",
+ PlanGenerationsRoot = 2,
+ MergedPlanPrsCount = 1,
+ MergeGroupsCount = 0,
+ },
+ FeaturePr = new StatusFeaturePrSection
+ {
+ Exists = true,
+ Number = 99,
+ Url = "https://github.com/o/r/pull/99",
+ State = "OPEN",
+ },
+ Binary = new StatusBinarySection
+ {
+ Version = "1.0.0.0",
+ InformationalVersion = "1.2.3",
+ Location = "/usr/local/bin/polyphony",
+ },
+ Warnings =
+ [
+ new StatusWarning { Code = "planned_tag_zero_children", Message = "msg" }
+ ],
+ Headline = "head",
+ NextAction = "next",
+ };
+
+ var json = JsonSerializer.Serialize(original, PolyphonyJsonContext.Default.StatusResult);
+ var rt = JsonSerializer.Deserialize(json, PolyphonyJsonContext.Default.StatusResult);
+
+ rt.ShouldNotBeNull();
+ rt.ApexId.ShouldBe(7);
+ rt.Ado.HasPlannedTag.ShouldBeTrue();
+ rt.Ado.Tags.Count.ShouldBe(3);
+ rt.Manifest.PlanGenerationsRoot.ShouldBe(2);
+ rt.FeaturePr.Number.ShouldBe(99);
+ rt.Warnings.Count.ShouldBe(1);
+ rt.Warnings[0].Code.ShouldBe("planned_tag_zero_children");
+ rt.NextAction.ShouldBe("next");
+ }
}
diff --git a/tests/Polyphony.Tests/Commands/StatusCommandTests.cs b/tests/Polyphony.Tests/Commands/StatusCommandTests.cs
new file mode 100644
index 00000000..4606e22a
--- /dev/null
+++ b/tests/Polyphony.Tests/Commands/StatusCommandTests.cs
@@ -0,0 +1,345 @@
+using System.Text.Json;
+using NSubstitute;
+using Polyphony.Commands;
+using Polyphony.Infrastructure.Processes;
+using Polyphony.Manifest;
+using Polyphony.Tests.TestFixtures;
+using Shouldly;
+using Xunit;
+
+namespace Polyphony.Tests.Commands;
+
+///
+/// Tests for — the routing-style dashboard verb.
+/// Routing-style means: ALWAYS exits 0; failure modes flow through per-section
+/// error fields and the cross-signal Warnings array. Tests pin
+/// that contract plus the five warning codes the command currently computes.
+///
+[Collection("CwdSerial")]
+public sealed class StatusCommandTests : CommandTestBase
+{
+ private readonly IGitClient _git = Substitute.For();
+ private readonly IGhClient _gh = Substitute.For();
+
+ public StatusCommandTests()
+ {
+ // Default: no remote URL → no slug → feature_pr section returns
+ // {exists:false, error:"no_slug"}. Tests that exercise the gh leg
+ // override this on the substitute directly.
+ _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(null));
+ }
+
+ private StatusCommand CreateCommand() => new(Repository, _git, _gh);
+
+ [Fact]
+ public async Task Status_MissingApex_ReturnsRoutingFailure_AndDoesNotEmitJson()
+ {
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status());
+
+ exitCode.ShouldBe(ExitCodes.RoutingFailure);
+ output.ShouldNotContain("\"apex_id\"");
+ }
+
+ [Fact]
+ public async Task Status_WorkItemNotFound_ReturnsResultWithFoundFalse_AndExitsZero()
+ {
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 999_999));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.ApexId.ShouldBe(999_999);
+ result.Ado.Found.ShouldBeFalse();
+ result.Ado.Error.ShouldNotBeNull();
+ result.Ado.Error!.ShouldContain("999999");
+ result.Headline.ShouldContain("not found");
+ }
+
+ [Fact]
+ public async Task Status_PlannedTagWithZeroChildren_EmitsFalseSatisfiedWarning()
+ {
+ // The AB#3064 false-satisfied bug: planned tag stamped on an apex
+ // that has no children. Headline takes the warning's wording.
+ var apex = new WorkItemBuilder()
+ .WithId(3064)
+ .WithType("Issue")
+ .WithTitle("Test apex")
+ .WithState("Doing")
+ .WithTags("polyphony; polyphony:root; polyphony:planned")
+ .Build();
+ await SeedAsync(apex);
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 3064));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.Ado.HasPlannedTag.ShouldBeTrue();
+ result.Ado.ChildrenCount.ShouldBe(0);
+ result.Warnings.ShouldContain(w => w.Code == "planned_tag_zero_children");
+ result.Headline.ShouldContain("planned but no children");
+ }
+
+ [Fact]
+ public async Task Status_ApexNotInScope_EmitsNotInScopeWarning()
+ {
+ // An ADO work item that exists but doesn't carry the polyphony tag.
+ // The dashboard catches this — it usually means the operator pointed
+ // status at the wrong work item.
+ var apex = new WorkItemBuilder()
+ .WithId(7)
+ .WithType("Issue")
+ .WithTitle("Wrong target")
+ .WithState("To Do")
+ .Build();
+ await SeedAsync(apex);
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 7));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.Ado.InScope.ShouldBeFalse();
+ result.Warnings.ShouldContain(w => w.Code == "apex_not_in_scope");
+ result.Headline.ShouldContain("not in polyphony scope");
+ result.NextAction.ShouldNotBeNull();
+ result.NextAction!.ShouldContain("polyphony root declare");
+ }
+
+ [Fact]
+ public async Task Status_InScopeButNotRoot_EmitsNotRootWarning()
+ {
+ var apex = new WorkItemBuilder()
+ .WithId(42)
+ .WithType("Task")
+ .WithTitle("descendant")
+ .WithState("Doing")
+ .WithTags("polyphony")
+ .Build();
+ await SeedAsync(apex);
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 42));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.Ado.InScope.ShouldBeTrue();
+ result.Ado.IsRoot.ShouldBeFalse();
+ result.Warnings.ShouldContain(w => w.Code == "apex_not_root");
+ }
+
+ [Fact]
+ public async Task Status_ManifestMissing_EmitsManifestMissingWarning_AndStillExitsZero()
+ {
+ var apex = new WorkItemBuilder()
+ .WithId(100)
+ .WithType("Issue")
+ .WithTitle("Healthy apex")
+ .WithState("Doing")
+ .WithTags("polyphony; polyphony:root")
+ .Build();
+ await SeedAsync(apex);
+
+ using var tempDir = new TempDirectory();
+ var missingManifest = Path.Combine(tempDir.Path, ".polyphony", "run.yaml");
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(
+ () => cmd.Status(apex: 100, manifestPath: missingManifest));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.Manifest.Exists.ShouldBeFalse();
+ result.Warnings.ShouldContain(w => w.Code == "manifest_missing");
+ }
+
+ [Fact]
+ public async Task Status_ManifestPresent_RootGenerationAndCountsSurfaced()
+ {
+ var apex = new WorkItemBuilder()
+ .WithId(200)
+ .WithType("Issue")
+ .WithTitle("Run-in-flight")
+ .WithState("Doing")
+ .WithTags("polyphony; polyphony:root")
+ .Build();
+ await SeedAsync(apex);
+
+ // Manifest exists, plan PRs merged, but no feature PR returned by gh.
+ // That's the unmerged-progress signal — pin both the surfaced fields
+ // and the warning.
+ _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult("https://github.com/owner/repo"));
+ _gh.ListPullRequestsAsync(
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>([]));
+
+ using var tempDir = new TempDirectory();
+ var manifestPath = Path.Combine(tempDir.Path, ".polyphony", "run.yaml");
+ Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
+ File.WriteAllText(manifestPath, """
+ schema: 1
+ root_id: 200
+ platform_project: dev.azure.com/test/Test
+ created_at: 2026-05-09T00:00:00Z
+ created_by: test
+ branch_model_version: 1
+ plan_generations:
+ root: 3
+ merged_plan_prs:
+ - pr_number: 1
+ item_key: root
+ merge_commit: abc123
+ previous_generation: 0
+ current_generation: 1
+ recorded_at: 2026-05-09T01:00:00Z
+ """);
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(
+ () => cmd.Status(apex: 200, manifestPath: manifestPath));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ if (result.Manifest.Error is not null)
+ {
+ // Surface the parser's own message so a schema drift surfaces
+ // as the test's failure message rather than a downstream null.
+ throw new Xunit.Sdk.XunitException(
+ $"Manifest failed to parse: {result.Manifest.Error}");
+ }
+ result.Manifest.Exists.ShouldBeTrue();
+ result.Manifest.FeatureBranch.ShouldBe("feature/200");
+ result.Manifest.PlanGenerationsRoot.ShouldBe(3);
+ result.Manifest.MergedPlanPrsCount.ShouldBe(1);
+ result.Warnings.ShouldContain(w => w.Code == "feature_pr_unmerged_progress");
+ }
+
+ [Fact]
+ public async Task Status_FeaturePrMerged_NoUnmergedProgressWarning_AndHeadlineReportsMerged()
+ {
+ var apex = new WorkItemBuilder()
+ .WithId(300)
+ .WithType("Issue")
+ .WithTitle("Shipped")
+ .WithState("Done")
+ .WithTags("polyphony; polyphony:root")
+ .Build();
+ await SeedAsync(apex);
+
+ using var tempDir = new TempDirectory();
+ var manifestPath = Path.Combine(tempDir.Path, ".polyphony", "run.yaml");
+ Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
+ File.WriteAllText(manifestPath, """
+ schema: 1
+ root_id: 300
+ platform_project: dev.azure.com/test/Test
+ created_at: 2026-05-09T00:00:00Z
+ created_by: test
+ branch_model_version: 1
+ merged_plan_prs:
+ - pr_number: 1
+ item_key: root
+ merge_commit: abc123
+ previous_generation: 0
+ current_generation: 1
+ recorded_at: 2026-05-09T01:00:00Z
+ """);
+
+ // gh reports a MERGED feature PR — exists+MergedAt populated.
+ _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult("git@github.com:owner/repo.git"));
+ _gh.ListPullRequestsAsync(
+ "owner/repo", Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>([
+ new PullRequestSummary(
+ Number: 42,
+ HeadRefName: "feature/300",
+ Url: "https://github.com/owner/repo/pull/42",
+ MergedAt: DateTimeOffset.Parse("2026-05-09T02:00:00Z"))
+ ]));
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(
+ () => cmd.Status(apex: 300, manifestPath: manifestPath));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.FeaturePr.Exists.ShouldBeTrue();
+ result.FeaturePr.Number.ShouldBe(42);
+ result.FeaturePr.State.ShouldBe("MERGED");
+ result.Warnings.ShouldNotContain(w => w.Code == "feature_pr_unmerged_progress");
+ result.Headline.ShouldContain("merged");
+ }
+
+ [Fact]
+ public async Task Status_GhFails_FeaturePrSectionCarriesError_AndExitZero()
+ {
+ var apex = new WorkItemBuilder()
+ .WithId(400)
+ .WithType("Issue")
+ .WithTitle("gh wedged")
+ .WithState("Doing")
+ .WithTags("polyphony; polyphony:root")
+ .Build();
+ await SeedAsync(apex);
+
+ _git.GetRemoteUrlAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult("https://github.com/owner/repo"));
+ _gh.ListPullRequestsAsync(
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns>>(_ =>
+ throw new InvalidOperationException("gh hung — buffered stderr: ..."));
+
+ var cmd = CreateCommand();
+ var (exitCode, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 400));
+
+ exitCode.ShouldBe(ExitCodes.Success);
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.FeaturePr.Exists.ShouldBeFalse();
+ result.FeaturePr.Error.ShouldNotBeNull();
+ result.FeaturePr.Error!.ShouldContain("gh hung");
+ }
+
+ [Fact]
+ public async Task Status_BinarySection_AlwaysPopulated()
+ {
+ var apex = new WorkItemBuilder().WithId(500).Build();
+ await SeedAsync(apex);
+
+ var cmd = CreateCommand();
+ var (_, output) = await CaptureConsoleAsync(() => cmd.Status(apex: 500));
+
+ var result = JsonSerializer.Deserialize(output, PolyphonyJsonContext.Default.StatusResult);
+ result.ShouldNotBeNull();
+ result.Binary.Version.ShouldNotBeNullOrEmpty();
+ result.Binary.InformationalVersion.ShouldNotBeNullOrEmpty();
+ }
+
+ /// Self-contained scratch directory that deletes on dispose.
+ private sealed class TempDirectory : IDisposable
+ {
+ public string Path { get; }
+ public TempDirectory()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "polyphony-status-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}