diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index ff78915..6fd7448 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index bda4ffb..bf09244 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -824,6 +824,275 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise, 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, + 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 = { + 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 { + 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 { + 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 { + 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 { 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( + `/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(`/api/v1/tasks/${encodeURIComponent(String(item.id))}/verdicts`), + deps.client.get(`/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