## batch-core — `packages/core` + `packages/dashboard/src` Shared branch: two workers are converting into it. Opening the PR because the branch was green with none, and a branch without a PR merges nothing. ### Census Measured with `node scripts/lifecycle-column-census.mjs --json`. | | guards | |---|---| | batch-core scope at branch point | 129 | | batch-core scope now | **92** (51 files) | | repo total now | 358 | Files closed so far: `store.ts` 11→0, `task-merge.ts` 6→0, `live-agent-count.ts` 6→0 (marked, not converted — see #2762), `task-update.ts` 3→0, display-ordering + Wake Delta ranking 5→0, `register-git-github.ts` 4→0. ### The `register-git-github.ts` slice Three PR routes — `pr/create`, `pr/push-branch`, `pr/resolve-conflicts` — plus the `CHANGES_REQUESTED` handler each compared `task.column !== "in-review"`. On a renamed board **none** of them matched, so every PR affordance the dashboard offers was refused for a card sitting in the lane that board calls review, and the refusal named a column that does not exist there. All four now share one helper, `reviewColumnsForTask`, which gets two things right that this program has repeatedly gotten wrong: - **Membership, not a single id.** It takes the broad review set (`mergeOrchestration ∪ mergeBlocker ∪ humanReview`). `resolveLifecycleColumns` returns the *first* column per trait, so a single-id answer silently ignores a board that declares a merge lane **and** a separate human sign-off lane. These guards only refuse or permit — they never move the card — so over-admitting costs nothing while under-admitting refuses a request that should have worked. - **An empty resolved set means UNEXPRESSED, not absent.** `synthesizeDefaultColumns` upgrades a v1 graph by emitting every default column with `traits: []`, so a v1-upgraded workflow resolves to an empty review set while its `in-review` column plainly exists and holds the card. Reading empty as "this board has no review lane" would refuse these routes on **every pre-v2 project** — a worse regression than the one being fixed, and invisible to any v2 test. This is the dashboard twin of the `fn pr create` guard in `packages/cli/src/commands/pr.ts` (#2775). The two surfaces answer the same question and now agree — FN-5893 surface enumeration. ### Testing note: why the seam and not the routes I wrote route-level HTTP tests first and **deleted them**. An express fixture over `registerGitGitHubRoutes` hangs — every case, including the pure refusals, times out at 4s, because registering the router starts background work the fixture never satisfies. Making it run would mean mocking git, the GitHub client, and the pollers: a mock-the-world shell, which is what the project's do-not-add-slow-tests rule (FN-5048) says to avoid in favour of a narrow seam. `reviewColumnsForTask` *is* the narrow seam — it holds the entire decision, and the four call sites now do nothing but ask it and render its answer. Six cases pin it: the renamed lane is returned and `in-review` is not, a two-lane board returns both, a v1-upgraded board falls back, an unresolvable workflow falls back, and the refusal renders lanes an operator can act on. **Mutation-verified, both directions:** reverting the helper to the legacy literal fails 2 of 6; treating an empty set as an answer fails 1 of 6. One fixture bug worth recording, since it would have made the two-lane case vacuous: the trait id is kebab-case `human-review`, not `humanReview`, and the built-in traits must be registered via `import "@fusion/core"` before flags resolve. ### Verification - `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` → 0 errors - `pnpm lint` → 0 errors - `register-git-github.review-lanes.test.ts` → 6 passed --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
import { isReviewColumnRole, isWipColumnRole, type ColumnRoleTraitFlags } from "./column-roles.js";
|
|
import { sql } from "drizzle-orm";
|
|
import type { Database } from "./db.js";
|
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
|
import { costFor, type CostResult, type ModelPricingOverrides } from "./model-pricing.js";
|
|
import type { TokenTotals } from "./token-analytics.js";
|
|
|
|
export interface TeamAnalyticsQuery {
|
|
/** ISO-8601 lower bound (inclusive). */
|
|
from?: string;
|
|
/** ISO-8601 upper bound (inclusive). */
|
|
to?: string;
|
|
/** Epoch ms "now" used only for pricing-staleness. */
|
|
now?: number;
|
|
/** User-managed pricing overrides that take precedence over the built-in baseline. */
|
|
pricingOverrides?: ModelPricingOverrides;
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-31-03:20 (batch-core feed):
|
|
Resolved trait flags per column NAME, so the wip/review tallies below come from the board's own
|
|
lanes. Omitted, core's role helpers fall back to the legacy ids — that degraded mode lives in
|
|
`column-roles.ts` and is tested there, so this file carries no hand-written fallback.
|
|
|
|
The failure this fixes is a SILENT ZERO. On a renamed board neither literal matched any row, so the
|
|
analytics surface reported `tasksInProgress: 0` and `tasksInReview: 0` beside token and cost totals
|
|
that were entirely correct. Plausible-looking numbers are worse than missing ones: a zero next to a
|
|
populated cost column reads as "nobody is working", not as "this metric is broken".
|
|
*/
|
|
columnFlagsByName?: ReadonlyMap<string, ColumnRoleTraitFlags>;
|
|
}
|
|
|
|
export interface TeamMetricTotals {
|
|
tokens: TokenTotals;
|
|
cost: CostResult;
|
|
filesChanged: number;
|
|
tasksCompleted: number;
|
|
tasksInProgress: number;
|
|
tasksInReview: number;
|
|
}
|
|
|
|
export interface TeamAgentSummary extends TeamMetricTotals {
|
|
agentId: string;
|
|
agentName: string | null;
|
|
role: string | null;
|
|
state: string | null;
|
|
}
|
|
|
|
export interface TeamAnalytics {
|
|
from: string | null;
|
|
to: string | null;
|
|
totals: TeamMetricTotals;
|
|
agents: TeamAgentSummary[];
|
|
}
|
|
|
|
interface AgentRow {
|
|
id: string;
|
|
name: string | null;
|
|
role: string | null;
|
|
state: string | null;
|
|
}
|
|
|
|
interface TaskTokenRow {
|
|
agentId: string;
|
|
inputTokens: number | null;
|
|
outputTokens: number | null;
|
|
cachedTokens: number | null;
|
|
cacheWriteTokens: number | null;
|
|
totalTokens: number | null;
|
|
modelProvider: string | null;
|
|
modelId: string | null;
|
|
tokenUsageModelProvider: string | null;
|
|
tokenUsageModelId: string | null;
|
|
}
|
|
|
|
interface CountByAgentRow {
|
|
agentId: string;
|
|
count: number;
|
|
}
|
|
|
|
interface ModifiedFilesRow {
|
|
agentId: string;
|
|
modifiedFiles: string | null;
|
|
}
|
|
|
|
function emptyTokenTotals(): TokenTotals {
|
|
return {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cachedTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
totalTokens: 0,
|
|
nTasks: 0,
|
|
};
|
|
}
|
|
|
|
interface CostAccumulator {
|
|
usd: number;
|
|
anyPriced: boolean;
|
|
anyUnavailable: boolean;
|
|
anyStale: boolean;
|
|
}
|
|
|
|
function emptyCostAccumulator(): CostAccumulator {
|
|
return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false };
|
|
}
|
|
|
|
function finalizeCost(acc: CostAccumulator): CostResult {
|
|
return {
|
|
usd: acc.anyPriced ? acc.usd : null,
|
|
unavailable: acc.anyUnavailable,
|
|
stale: acc.anyStale,
|
|
};
|
|
}
|
|
|
|
function addTokenRow(totals: TokenTotals, row: TaskTokenRow): void {
|
|
totals.inputTokens += row.inputTokens ?? 0;
|
|
totals.outputTokens += row.outputTokens ?? 0;
|
|
totals.cachedTokens += row.cachedTokens ?? 0;
|
|
totals.cacheWriteTokens += row.cacheWriteTokens ?? 0;
|
|
totals.totalTokens +=
|
|
row.totalTokens ??
|
|
(row.inputTokens ?? 0) +
|
|
(row.outputTokens ?? 0) +
|
|
(row.cachedTokens ?? 0) +
|
|
(row.cacheWriteTokens ?? 0);
|
|
totals.nTasks += 1;
|
|
}
|
|
|
|
function addRowCost(
|
|
acc: CostAccumulator,
|
|
row: TaskTokenRow,
|
|
now?: number,
|
|
pricingOverrides?: ModelPricingOverrides,
|
|
): void {
|
|
const result = costFor(
|
|
{
|
|
inputTokens: row.inputTokens ?? 0,
|
|
outputTokens: row.outputTokens ?? 0,
|
|
cachedTokens: row.cachedTokens ?? 0,
|
|
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
|
},
|
|
{
|
|
/*
|
|
* FNXC:CommandCenter 2026-07-10-08:25:
|
|
* Current runtime usage rows persist the actually-used model in tokenUsageModelProvider/tokenUsageModelId while task-level modelProvider/modelId can stay empty. Team cost analytics must price the usage snapshot first so estimated cost survives model-resolution hierarchy and catalog drift instead of reverting to the unavailable sentinel.
|
|
*/
|
|
provider: row.tokenUsageModelProvider ?? row.modelProvider,
|
|
model: row.tokenUsageModelId ?? row.modelId,
|
|
},
|
|
now,
|
|
pricingOverrides,
|
|
);
|
|
if (result.stale) acc.anyStale = true;
|
|
if (result.unavailable || result.usd === null) {
|
|
acc.anyUnavailable = true;
|
|
} else {
|
|
acc.usd += result.usd;
|
|
acc.anyPriced = true;
|
|
}
|
|
}
|
|
|
|
function emptyMetricTotals(): TeamMetricTotals {
|
|
return {
|
|
tokens: emptyTokenTotals(),
|
|
cost: { usd: null, unavailable: false, stale: false },
|
|
filesChanged: 0,
|
|
tasksCompleted: 0,
|
|
tasksInProgress: 0,
|
|
tasksInReview: 0,
|
|
};
|
|
}
|
|
|
|
function countModifiedFiles(value: string | null): number {
|
|
if (!value) return 0;
|
|
let files: unknown;
|
|
try {
|
|
files = JSON.parse(value);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
if (!Array.isArray(files)) return 0;
|
|
let count = 0;
|
|
for (const file of files) {
|
|
if (typeof file === "string" && file.length > 0) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function addRangeClauses(column: string, clauses: string[], params: string[], query: TeamAnalyticsQuery): void {
|
|
if (query.from !== undefined) {
|
|
clauses.push(`${column} >= ?`);
|
|
params.push(query.from);
|
|
}
|
|
if (query.to !== undefined) {
|
|
clauses.push(`${column} <= ?`);
|
|
params.push(query.to);
|
|
}
|
|
}
|
|
|
|
function makeSummary(agentId: string, agent?: AgentRow): TeamAgentSummary {
|
|
return {
|
|
agentId,
|
|
agentName: agent?.name ?? null,
|
|
role: agent?.role ?? null,
|
|
state: agent?.state ?? null,
|
|
...emptyMetricTotals(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aggregate store-derived per-agent Command Center metrics over a date range.
|
|
*
|
|
* FNXC:CommandCenter 2026-06-18-16:57:
|
|
* Team analytics derives per-agent tokens/cost, files changed, and tasks completed from the tasks+agents tables only; no new schema, no GitHub-issue data (that is FN-6653). Keep the aggregator pure/read-only and project-scoped by accepting the already-scoped Database handle from the HTTP layer.
|
|
*/
|
|
export async function aggregateTeamAnalytics(
|
|
dbOrLayer: Database | AsyncDataLayer,
|
|
query: TeamAnalyticsQuery = {},
|
|
): Promise<TeamAnalytics> {
|
|
// FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
|
// Backend (PostgreSQL) path. Fetch agents + the four task-derived row sets
|
|
// from schema-qualified project.* tables (snake_case columns; the async
|
|
// connection has no `project` on search_path), then run the identical pure
|
|
// per-agent aggregation as the sync branch via buildTeamAnalytics.
|
|
if ("ping" in dbOrLayer) {
|
|
return aggregateTeamAnalyticsAsync(dbOrLayer, query);
|
|
}
|
|
const db = dbOrLayer as Database;
|
|
|
|
const agents = db
|
|
.prepare(`SELECT id, name, role, state FROM agents ORDER BY id`)
|
|
.all() as AgentRow[];
|
|
|
|
const tokenClauses = ["assignedAgentId IS NOT NULL", "tokenUsageLastUsedAt IS NOT NULL"];
|
|
const tokenParams: string[] = [];
|
|
addRangeClauses("tokenUsageLastUsedAt", tokenClauses, tokenParams, query);
|
|
const tokenRows = db
|
|
.prepare(
|
|
`SELECT
|
|
assignedAgentId AS agentId,
|
|
tokenUsageInputTokens AS inputTokens,
|
|
tokenUsageOutputTokens AS outputTokens,
|
|
tokenUsageCachedTokens AS cachedTokens,
|
|
tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
|
tokenUsageTotalTokens AS totalTokens,
|
|
modelProvider,
|
|
modelId,
|
|
tokenUsageModelProvider,
|
|
tokenUsageModelId
|
|
FROM tasks
|
|
WHERE ${tokenClauses.join(" AND ")}`,
|
|
)
|
|
.all(...tokenParams) as TaskTokenRow[];
|
|
|
|
const completedClauses = ["assignedAgentId IS NOT NULL", `"column" = 'done'`, "columnMovedAt IS NOT NULL"];
|
|
const completedParams: string[] = [];
|
|
addRangeClauses("columnMovedAt", completedClauses, completedParams, query);
|
|
const completedRows = db
|
|
.prepare(
|
|
`SELECT assignedAgentId AS agentId, COUNT(*) AS count
|
|
FROM tasks
|
|
WHERE ${completedClauses.join(" AND ")}
|
|
GROUP BY assignedAgentId`,
|
|
)
|
|
.all(...completedParams) as CountByAgentRow[];
|
|
|
|
const currentRows = db
|
|
.prepare(
|
|
`SELECT assignedAgentId AS agentId, "column" AS columnName, COUNT(*) AS count
|
|
FROM tasks
|
|
WHERE assignedAgentId IS NOT NULL AND "column" IN ('in-progress', 'in-review')
|
|
GROUP BY assignedAgentId, "column"`,
|
|
)
|
|
.all() as Array<CountByAgentRow & { columnName: string }>;
|
|
|
|
const filesClauses = ["assignedAgentId IS NOT NULL", "modifiedFiles IS NOT NULL", "modifiedFiles NOT IN ('', '[]')"];
|
|
const filesParams: string[] = [];
|
|
addRangeClauses("updatedAt", filesClauses, filesParams, query);
|
|
const fileRows = db
|
|
.prepare(
|
|
`SELECT assignedAgentId AS agentId, modifiedFiles
|
|
FROM tasks
|
|
WHERE ${filesClauses.join(" AND ")}`,
|
|
)
|
|
.all(...filesParams) as ModifiedFilesRow[];
|
|
|
|
return buildTeamAnalytics(agents, tokenRows, completedRows, currentRows, fileRows, query);
|
|
}
|
|
|
|
/**
|
|
* FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
|
* PostgreSQL fetch path for {@link aggregateTeamAnalytics}. Modified-files is
|
|
* jsonb (postgres-js returns it parsed), so countModifiedFiles receives a
|
|
* re-stringified value to keep the legacy JSON.parse path identical.
|
|
*/
|
|
async function aggregateTeamAnalyticsAsync(
|
|
layer: AsyncDataLayer,
|
|
query: TeamAnalyticsQuery,
|
|
): Promise<TeamAnalytics> {
|
|
const agents = (await layer.db.execute(
|
|
sql`SELECT id, name, role, state FROM project.agents ORDER BY id`,
|
|
)) as unknown as AgentRow[];
|
|
|
|
const tokFrom = query.from !== undefined ? sql`AND token_usage_last_used_at >= ${query.from}` : sql``;
|
|
const tokTo = query.to !== undefined ? sql`AND token_usage_last_used_at <= ${query.to}` : sql``;
|
|
const tokenRowsRaw = (await layer.db.execute(
|
|
sql`SELECT
|
|
assigned_agent_id AS "agentId",
|
|
token_usage_input_tokens AS "inputTokens",
|
|
token_usage_output_tokens AS "outputTokens",
|
|
token_usage_cached_tokens AS "cachedTokens",
|
|
token_usage_cache_write_tokens AS "cacheWriteTokens",
|
|
token_usage_total_tokens AS "totalTokens",
|
|
model_provider AS "modelProvider",
|
|
model_id AS "modelId",
|
|
token_usage_model_provider AS "tokenUsageModelProvider",
|
|
token_usage_model_id AS "tokenUsageModelId"
|
|
FROM project.tasks
|
|
WHERE assigned_agent_id IS NOT NULL AND token_usage_last_used_at IS NOT NULL ${tokFrom} ${tokTo}`,
|
|
)) as Array<Record<string, unknown>>;
|
|
const tokenRows: TaskTokenRow[] = tokenRowsRaw.map((r) => ({
|
|
agentId: String(r.agentId),
|
|
inputTokens: r.inputTokens == null ? null : Number(r.inputTokens),
|
|
outputTokens: r.outputTokens == null ? null : Number(r.outputTokens),
|
|
cachedTokens: r.cachedTokens == null ? null : Number(r.cachedTokens),
|
|
cacheWriteTokens: r.cacheWriteTokens == null ? null : Number(r.cacheWriteTokens),
|
|
totalTokens: r.totalTokens == null ? null : Number(r.totalTokens),
|
|
modelProvider: (r.modelProvider as string | null) ?? null,
|
|
modelId: (r.modelId as string | null) ?? null,
|
|
tokenUsageModelProvider: (r.tokenUsageModelProvider as string | null) ?? null,
|
|
tokenUsageModelId: (r.tokenUsageModelId as string | null) ?? null,
|
|
}));
|
|
|
|
const compFrom = query.from !== undefined ? sql`AND column_moved_at >= ${query.from}` : sql``;
|
|
const compTo = query.to !== undefined ? sql`AND column_moved_at <= ${query.to}` : sql``;
|
|
const completedRows = (await layer.db.execute(
|
|
sql`SELECT assigned_agent_id AS "agentId", count(*)::int AS count
|
|
FROM project.tasks
|
|
WHERE assigned_agent_id IS NOT NULL AND "column" = 'done' AND column_moved_at IS NOT NULL ${compFrom} ${compTo}
|
|
GROUP BY assigned_agent_id`,
|
|
)) as unknown as CountByAgentRow[];
|
|
|
|
const currentRows = (await layer.db.execute(
|
|
sql`SELECT assigned_agent_id AS "agentId", "column" AS "columnName", count(*)::int AS count
|
|
FROM project.tasks
|
|
WHERE assigned_agent_id IS NOT NULL AND "column" IN ('in-progress', 'in-review')
|
|
GROUP BY assigned_agent_id, "column"`,
|
|
)) as unknown as Array<CountByAgentRow & { columnName: string }>;
|
|
|
|
const filesFrom = query.from !== undefined ? sql`AND updated_at >= ${query.from}` : sql``;
|
|
const filesTo = query.to !== undefined ? sql`AND updated_at <= ${query.to}` : sql``;
|
|
const fileRowsRaw = (await layer.db.execute(
|
|
sql`SELECT assigned_agent_id AS "agentId", modified_files AS "modifiedFiles"
|
|
FROM project.tasks
|
|
WHERE assigned_agent_id IS NOT NULL
|
|
AND modified_files IS NOT NULL
|
|
AND jsonb_typeof(modified_files) = 'array'
|
|
AND jsonb_array_length(modified_files) > 0
|
|
${filesFrom} ${filesTo}`,
|
|
)) as Array<{ agentId: string; modifiedFiles: unknown }>;
|
|
const fileRows: ModifiedFilesRow[] = fileRowsRaw.map((r) => ({
|
|
agentId: String(r.agentId),
|
|
modifiedFiles: r.modifiedFiles == null ? null : JSON.stringify(r.modifiedFiles),
|
|
}));
|
|
|
|
return buildTeamAnalytics(agents, tokenRows, completedRows, currentRows, fileRows, query);
|
|
}
|
|
|
|
/**
|
|
* FNXC:PostgresCommandCenterAnalytics 2026-06-27-10:00:
|
|
* Pure per-agent aggregation shared by the sync (SQLite) and async (PostgreSQL)
|
|
* paths of {@link aggregateTeamAnalytics}. No I/O — takes already-fetched rows.
|
|
*/
|
|
function buildTeamAnalytics(
|
|
agents: AgentRow[],
|
|
tokenRows: TaskTokenRow[],
|
|
completedRows: CountByAgentRow[],
|
|
currentRows: Array<CountByAgentRow & { columnName: string }>,
|
|
fileRows: ModifiedFilesRow[],
|
|
query: TeamAnalyticsQuery,
|
|
): TeamAnalytics {
|
|
const summaries = new Map<string, TeamAgentSummary>();
|
|
const costAccumulators = new Map<string, CostAccumulator>();
|
|
const totalTokens = emptyTokenTotals();
|
|
const totalCost = emptyCostAccumulator();
|
|
const pricingOverrides = query.pricingOverrides;
|
|
|
|
for (const agent of agents) {
|
|
summaries.set(agent.id, makeSummary(agent.id, agent));
|
|
costAccumulators.set(agent.id, emptyCostAccumulator());
|
|
}
|
|
|
|
const ensureSummary = (agentId: string): TeamAgentSummary => {
|
|
const existing = summaries.get(agentId);
|
|
if (existing) return existing;
|
|
const created = makeSummary(agentId);
|
|
summaries.set(agentId, created);
|
|
costAccumulators.set(agentId, emptyCostAccumulator());
|
|
return created;
|
|
};
|
|
|
|
for (const row of tokenRows) {
|
|
const summary = ensureSummary(row.agentId);
|
|
const agentCost = costAccumulators.get(row.agentId) ?? emptyCostAccumulator();
|
|
costAccumulators.set(row.agentId, agentCost);
|
|
addTokenRow(summary.tokens, row);
|
|
addTokenRow(totalTokens, row);
|
|
addRowCost(agentCost, row, query.now, pricingOverrides);
|
|
addRowCost(totalCost, row, query.now, pricingOverrides);
|
|
}
|
|
|
|
for (const row of completedRows) {
|
|
ensureSummary(row.agentId).tasksCompleted = row.count;
|
|
}
|
|
|
|
for (const row of currentRows) {
|
|
const summary = ensureSummary(row.agentId);
|
|
const columnFlags = query.columnFlagsByName?.get(row.columnName);
|
|
if (isWipColumnRole(columnFlags, row.columnName)) summary.tasksInProgress = row.count;
|
|
if (isReviewColumnRole(columnFlags, row.columnName)) summary.tasksInReview = row.count;
|
|
}
|
|
|
|
for (const row of fileRows) {
|
|
ensureSummary(row.agentId).filesChanged += countModifiedFiles(row.modifiedFiles);
|
|
}
|
|
|
|
for (const [agentId, summary] of summaries) {
|
|
summary.cost = finalizeCost(costAccumulators.get(agentId) ?? emptyCostAccumulator());
|
|
}
|
|
|
|
let filesChanged = 0;
|
|
let tasksCompleted = 0;
|
|
let tasksInProgress = 0;
|
|
let tasksInReview = 0;
|
|
for (const summary of summaries.values()) {
|
|
filesChanged += summary.filesChanged;
|
|
tasksCompleted += summary.tasksCompleted;
|
|
tasksInProgress += summary.tasksInProgress;
|
|
tasksInReview += summary.tasksInReview;
|
|
}
|
|
|
|
const sortedAgents = [...summaries.values()].sort((a, b) => {
|
|
const tokenCmp = b.tokens.totalTokens - a.tokens.totalTokens;
|
|
if (tokenCmp !== 0) return tokenCmp;
|
|
return a.agentId.localeCompare(b.agentId);
|
|
});
|
|
|
|
return {
|
|
from: query.from ?? null,
|
|
to: query.to ?? null,
|
|
totals: {
|
|
tokens: totalTokens,
|
|
cost: finalizeCost(totalCost),
|
|
filesChanged,
|
|
tasksCompleted,
|
|
tasksInProgress,
|
|
tasksInReview,
|
|
},
|
|
agents: sortedAgents,
|
|
};
|
|
}
|