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:
Michal
2026-08-10 12:11:24 +01:00
parent 60db1be531
commit 2a305f7045
2 changed files with 314 additions and 4 deletions

View File

@@ -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"
]

View File

@@ -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