feat(cli): describe formatters for the agentic-teams resources
Closes 167. The five new resources fell through to the generic key-value dump,
against the project rule that describe shows clean sections and tables. More to
the point, two things built earlier had no way to reach a human at all: the
roadmap endpoint had no client, and the Epic reconciliation was only visible as
raw JSON.
`describe epic` puts the delivery mismatch directly under the completeness
percentage it contradicts, because reading either one alone is the bug. Rendered
against a live mcpd:
Completeness: [....................] 0% (0/3)
...
Delivery: MISMATCH between what is claimed and what shipped
Tasks: 1 merged, 0 outstanding, 0 cancelled
Delivered, not claimed:
- wheels (1 merged, 0 outstanding)
No roadmap node owns it:
- engine, feel
plus the criteria checklist, accepted tradeoffs, and the roadmap with per-node
completeness and READY flags.
`describe task` derives the gates a task still owes from its risk and recorded
verdicts ("awaiting security_review, sre_review" vs "all passed"), and shows the
legal next transitions, PR linkage, verdicts and full history — so the answer to
"why won't this move" is on screen rather than requiring a failed attempt.
`describe repo` / `describe gitprovider` show the credential REFERENCE only.
The resolved value exists solely on the session-provision path and must never
reach a describe.
The roadmap, verdict and event fetches are all best-effort: an older mcpd 404s
and the resource still renders, rather than the whole describe failing over an
enrichment.
One bug found by looking at the output rather than the types: the Git section
opened on `!== undefined`, but JSON hands back null, so every task without a PR
printed an empty `Git:` header.
Local: build clean, workspace 2557 passed, lint unchanged at 794 errors.
Verified live against a local mcpd for epic, epictask and task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -3315,9 +3315,9 @@
|
||||
"dependencies": [
|
||||
"136"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-09T19:36:34.052Z"
|
||||
"updatedAt": "2026-08-10T11:11:23.911Z"
|
||||
},
|
||||
{
|
||||
"id": "168",
|
||||
@@ -3461,9 +3461,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-08-09T22:36:53.472Z",
|
||||
"lastModified": "2026-08-10T11:11:23.912Z",
|
||||
"taskCount": 177,
|
||||
"completedCount": 133,
|
||||
"completedCount": 134,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -824,6 +824,275 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agentic teams ──
|
||||
|
||||
interface DoneCriterion { id: string; text: string; kind?: string; met?: boolean }
|
||||
interface Completeness { total: number; met: number; percent: number; unmet?: DoneCriterion[] }
|
||||
interface CriterionDelivery { id: string; text?: string; met?: boolean; merged?: number; outstanding?: number; cancelled?: number }
|
||||
interface Reconciliation {
|
||||
deliveredButUnclaimed?: CriterionDelivery[];
|
||||
claimedButUndelivered?: CriterionDelivery[];
|
||||
unowned?: CriterionDelivery[];
|
||||
tasks?: { merged: number; outstanding: number; cancelled: number; total: number };
|
||||
consistent?: boolean;
|
||||
}
|
||||
interface RoadmapNode {
|
||||
id: string; name: string; status: string;
|
||||
completeness?: Completeness; dependsOn?: string[]; blocks?: string[];
|
||||
}
|
||||
export interface RoadmapInfo { nodes?: RoadmapNode[]; ready?: string[] }
|
||||
|
||||
function bar(percent: number, width = 20): string {
|
||||
const filled = Math.round((percent / 100) * width);
|
||||
return `[${'#'.repeat(filled)}${'.'.repeat(width - filled)}]`;
|
||||
}
|
||||
|
||||
function formatEpicDetail(epic: Record<string, unknown>, roadmap?: RoadmapInfo): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Epic: ${String(epic.name)} ===`);
|
||||
lines.push(`${pad('Name:')}${String(epic.name)}`);
|
||||
const project = epic.project as { name?: string } | undefined;
|
||||
lines.push(`${pad('Project:')}${project?.name ?? '-'}`);
|
||||
lines.push(`${pad('Status:')}${String(epic.status)}`);
|
||||
lines.push(`${pad('Semver:')}${String(epic.semver)}`);
|
||||
if (epic.description !== undefined && epic.description !== '') {
|
||||
lines.push(`${pad('Description:')}${String(epic.description)}`);
|
||||
}
|
||||
|
||||
const c = epic.completeness as Completeness | undefined;
|
||||
if (c !== undefined) {
|
||||
lines.push('');
|
||||
lines.push(`Completeness: ${bar(c.percent)} ${String(c.percent)}% (${String(c.met)}/${String(c.total)})`);
|
||||
}
|
||||
|
||||
const criteria = (epic.doneCriteria ?? []) as DoneCriterion[];
|
||||
if (criteria.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Done criteria:');
|
||||
for (const cr of criteria) {
|
||||
const mark = cr.met === true ? '[x]' : '[ ]';
|
||||
const kind = cr.kind === 'quality' ? ' (quality)' : '';
|
||||
lines.push(` ${mark} ${pad(cr.id, 22)}${cr.text}${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The reconciliation sits directly under the percentage it contradicts —
|
||||
// reading one without the other is the bug this whole feature exists for.
|
||||
const rec = epic.reconciliation as Reconciliation | undefined;
|
||||
if (rec !== undefined) {
|
||||
lines.push('');
|
||||
if (rec.consistent === true) {
|
||||
lines.push('Delivery: claims and delivered work agree');
|
||||
} else {
|
||||
lines.push('Delivery: MISMATCH between what is claimed and what shipped');
|
||||
}
|
||||
if (rec.tasks !== undefined) {
|
||||
lines.push(` ${pad('Tasks:', 22)}${String(rec.tasks.merged)} merged, ${String(rec.tasks.outstanding)} outstanding, ${String(rec.tasks.cancelled)} cancelled`);
|
||||
}
|
||||
const buckets: Array<[string, CriterionDelivery[] | undefined]> = [
|
||||
['Delivered, not claimed', rec.deliveredButUnclaimed],
|
||||
['Claimed, nothing shipped', rec.claimedButUndelivered],
|
||||
['No roadmap node owns it', rec.unowned],
|
||||
];
|
||||
for (const [label, rows] of buckets) {
|
||||
if (rows === undefined || rows.length === 0) continue;
|
||||
lines.push(` ${label}:`);
|
||||
for (const r of rows) {
|
||||
const counts = r.merged !== undefined
|
||||
? ` (${String(r.merged)} merged, ${String(r.outstanding ?? 0)} outstanding)`
|
||||
: '';
|
||||
lines.push(` - ${r.id}${counts}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tradeoffs = (epic.acceptedTradeoffs ?? []) as Array<{ what?: string; why?: string; who?: string }>;
|
||||
if (tradeoffs.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Accepted tradeoffs:');
|
||||
for (const t of tradeoffs) {
|
||||
lines.push(` - ${t.what ?? '?'}`);
|
||||
lines.push(` why: ${t.why ?? '?'}${t.who !== undefined ? ` (${t.who})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (roadmap?.nodes !== undefined && roadmap.nodes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Roadmap:');
|
||||
const ready = new Set(roadmap.ready ?? []);
|
||||
for (const n of roadmap.nodes) {
|
||||
const pct = n.completeness !== undefined ? `${String(n.completeness.percent)}%` : '-';
|
||||
const flags = [
|
||||
ready.has(n.id) ? 'READY' : '',
|
||||
(n.dependsOn ?? []).length > 0 ? `waits on ${String((n.dependsOn ?? []).length)}` : '',
|
||||
].filter((f) => f !== '').join(', ');
|
||||
lines.push(` ${pad(n.name, 26)}${pad(n.status, 14)}${pad(pct, 6)}${flags}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (epic.content !== undefined && epic.content !== '') {
|
||||
lines.push('');
|
||||
lines.push('Vision:');
|
||||
for (const line of String(epic.content).split('\n')) lines.push(` ${line}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
interface Verdict { stage: string; commit: string; passed: boolean; summary: string }
|
||||
interface TaskEvent { fromState: string | null; toState: string; actorType: string; note: string | null; createdAt: string }
|
||||
|
||||
function formatTaskDetail(
|
||||
task: Record<string, unknown>,
|
||||
verdicts?: Verdict[],
|
||||
events?: TaskEvent[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Task: ${String(task.name)} ===`);
|
||||
lines.push(`${pad('Name:')}${String(task.name)}`);
|
||||
lines.push(`${pad('Title:')}${String(task.title)}`);
|
||||
lines.push(`${pad('State:')}${String(task.state)}`);
|
||||
lines.push(`${pad('Risk:')}${String(task.risk)}`);
|
||||
const assignee = task.assignee as { name?: string } | null | undefined;
|
||||
lines.push(`${pad('Assignee:')}${assignee?.name ?? '(unassigned)'}`);
|
||||
const repo = task.repo as { name?: string } | null | undefined;
|
||||
if (repo?.name !== undefined) lines.push(`${pad('Repo:')}${repo.name}`);
|
||||
|
||||
const legal = (task.legalTransitions ?? []) as string[];
|
||||
if (legal.length > 0) {
|
||||
lines.push(`${pad('Can move to:')}${legal.join(', ')}`);
|
||||
} else {
|
||||
lines.push(`${pad('Can move to:')}(terminal)`);
|
||||
}
|
||||
|
||||
// Which gates this task still owes, derived from risk + recorded verdicts.
|
||||
const REQUIRED: Record<string, string[]> = {
|
||||
trivial: [], normal: ['qa_review'], high: ['qa_review', 'security_review', 'sre_review'],
|
||||
};
|
||||
const required = REQUIRED[String(task.risk)] ?? [];
|
||||
if (required.length > 0) {
|
||||
const passed = new Set((verdicts ?? []).filter((v) => v.passed).map((v) => v.stage));
|
||||
const missing = required.filter((r) => !passed.has(r));
|
||||
lines.push(`${pad('Gates:')}${missing.length === 0 ? 'all passed' : `awaiting ${missing.join(', ')}`}`);
|
||||
}
|
||||
|
||||
// JSON hands back null, not undefined, so a bare `!== undefined` check opens
|
||||
// the section and then filters every line out of it.
|
||||
const hasGit = [task.branch, task.headCommit, task.prUrl]
|
||||
.some((v) => v !== undefined && v !== null && v !== '');
|
||||
if (hasGit) {
|
||||
lines.push('');
|
||||
lines.push('Git:');
|
||||
if (task.branch !== undefined && task.branch !== null) lines.push(` ${pad('Branch:', 14)}${String(task.branch)}`);
|
||||
if (task.headCommit !== undefined && task.headCommit !== null) lines.push(` ${pad('Commit:', 14)}${String(task.headCommit)}`);
|
||||
if (task.prUrl !== undefined && task.prUrl !== null) lines.push(` ${pad('PR:', 14)}${String(task.prUrl)}`);
|
||||
}
|
||||
|
||||
if (task.reviewNote !== undefined && task.reviewNote !== null && task.reviewNote !== '') {
|
||||
lines.push('');
|
||||
lines.push(`Review note: ${String(task.reviewNote)}`);
|
||||
}
|
||||
|
||||
if (verdicts !== undefined && verdicts.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Verdicts:');
|
||||
for (const v of verdicts) {
|
||||
lines.push(` ${v.passed ? 'PASS' : 'FAIL'} ${pad(v.stage, 18)}@${v.commit.slice(0, 8)} ${v.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('History:');
|
||||
for (const e of events) {
|
||||
const when = new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19);
|
||||
lines.push(` ${when} ${pad(e.fromState ?? '(new)', 16)}→ ${pad(e.toState, 16)}[${e.actorType}]${e.note !== null ? ` ${e.note}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (task.description !== undefined && task.description !== '') {
|
||||
lines.push('');
|
||||
lines.push('Description:');
|
||||
for (const line of String(task.description).split('\n')) lines.push(` ${line}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatCodeRepoDetail(repo: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Repo: ${String(repo.name)} ===`);
|
||||
lines.push(`${pad('Name:')}${String(repo.name)}`);
|
||||
const project = repo.project as { name?: string } | undefined;
|
||||
lines.push(`${pad('Project:')}${project?.name ?? '-'}`);
|
||||
lines.push(`${pad('URL:')}${String(repo.url)}`);
|
||||
lines.push(`${pad('Branch:')}${String(repo.defaultBranch)}`);
|
||||
lines.push(`${pad('Path:')}${repo.path === '' ? `${String(repo.name)} (default)` : String(repo.path)}`);
|
||||
lines.push(`${pad('Provider:')}${String(repo.provider)}`);
|
||||
|
||||
if (repo.sourceFullName !== undefined && repo.sourceFullName !== null) {
|
||||
lines.push('');
|
||||
lines.push('Imported from:');
|
||||
lines.push(` ${pad('Forge:', 14)}${String(repo.sourceProvider ?? '(disconnected)')}`);
|
||||
lines.push(` ${pad('Repository:', 14)}${String(repo.sourceFullName)}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Credential:');
|
||||
// Reference only. The resolved value exists solely on the session-provision
|
||||
// path and must never reach a describe.
|
||||
if (repo.credSecretId !== undefined && repo.credSecretId !== null) {
|
||||
lines.push(` ${pad('Key:', 14)}${String(repo.credSecretKey)} (from a secret)`);
|
||||
} else if (repo.gitProviderId !== undefined && repo.gitProviderId !== null) {
|
||||
lines.push(` inherited from forge '${String(repo.sourceProvider ?? '?')}'`);
|
||||
} else {
|
||||
lines.push(' (none — public or ambient auth)');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatGitProviderDetail(gp: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== GitProvider: ${String(gp.name)} ===`);
|
||||
lines.push(`${pad('Name:')}${String(gp.name)}`);
|
||||
lines.push(`${pad('Type:')}${String(gp.type)}`);
|
||||
lines.push(`${pad('URL:')}${String(gp.url)}`);
|
||||
if (gp.description !== undefined && gp.description !== '') {
|
||||
lines.push(`${pad('Description:')}${String(gp.description)}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Credential:');
|
||||
if (gp.credSecretId !== undefined && gp.credSecretId !== null) {
|
||||
lines.push(` ${pad('Key:', 14)}${String(gp.credSecretKey)} (from a secret)`);
|
||||
} else {
|
||||
lines.push(' (none — anonymous access only)');
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(`Discover repositories: mcpctl repos discover ${String(gp.name)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatEpicTaskDetail(node: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== EpicTask: ${String(node.name)} ===`);
|
||||
lines.push(`${pad('Name:')}${String(node.name)}`);
|
||||
const epic = node.epic as { name?: string } | undefined;
|
||||
lines.push(`${pad('Epic:')}${epic?.name ?? '-'}`);
|
||||
lines.push(`${pad('Status:')}${String(node.status)}`);
|
||||
if (node.description !== undefined && node.description !== '') {
|
||||
lines.push(`${pad('Description:')}${String(node.description)}`);
|
||||
}
|
||||
const criteria = (node.doneCriteria ?? []) as DoneCriterion[];
|
||||
lines.push('');
|
||||
if (criteria.length === 0) {
|
||||
lines.push('Owns no done-criteria (nothing on the Epic is measured by this node)');
|
||||
} else {
|
||||
lines.push('Owns these Epic criteria:');
|
||||
for (const cr of criteria) {
|
||||
lines.push(` ${cr.met === true ? '[x]' : '[ ]'} ${pad(cr.id, 22)}${cr.text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatProxymodelDetail(model: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
const modelType = (model.type as string | undefined) ?? 'pipeline';
|
||||
@@ -1013,6 +1282,47 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'secretbackends':
|
||||
deps.log(formatSecretBackendDetail(item));
|
||||
break;
|
||||
case 'epics': {
|
||||
// The roadmap lives behind its own endpoint and had no client at
|
||||
// all until now. Best-effort: an older mcpd 404s and we render the
|
||||
// Epic alone rather than failing the whole describe.
|
||||
let roadmap: RoadmapInfo | undefined;
|
||||
try {
|
||||
roadmap = await deps.client.get<RoadmapInfo>(
|
||||
`/api/v1/epics/${encodeURIComponent(String(item.id))}/roadmap`,
|
||||
);
|
||||
} catch {
|
||||
roadmap = undefined;
|
||||
}
|
||||
deps.log(formatEpicDetail(item, roadmap));
|
||||
break;
|
||||
}
|
||||
case 'epictasks':
|
||||
deps.log(formatEpicTaskDetail(item));
|
||||
break;
|
||||
case 'tasks': {
|
||||
// Verdicts drive the "which gates does this still owe" line, and
|
||||
// the history is the audit trail — both are separate endpoints.
|
||||
let verdicts: Verdict[] | undefined;
|
||||
let events: TaskEvent[] | undefined;
|
||||
try {
|
||||
[verdicts, events] = await Promise.all([
|
||||
deps.client.get<Verdict[]>(`/api/v1/tasks/${encodeURIComponent(String(item.id))}/verdicts`),
|
||||
deps.client.get<TaskEvent[]>(`/api/v1/tasks/${encodeURIComponent(String(item.id))}/events`),
|
||||
]);
|
||||
} catch {
|
||||
verdicts = undefined;
|
||||
events = undefined;
|
||||
}
|
||||
deps.log(formatTaskDetail(item, verdicts, events));
|
||||
break;
|
||||
}
|
||||
case 'repos':
|
||||
deps.log(formatCodeRepoDetail(item));
|
||||
break;
|
||||
case 'gitproviders':
|
||||
deps.log(formatGitProviderDetail(item));
|
||||
break;
|
||||
case 'llms': {
|
||||
// v4: also fetch pool membership so the describe Pool block
|
||||
// can show siblings + active counts. Best-effort — older
|
||||
|
||||
Reference in New Issue
Block a user