Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/cli/hud/components/decisions-counts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as fs from 'node:fs';
import type { ComponentResult, GatherContext, DecisionsCountsData } from '../types.js';
import { dim } from '../colors.js';
import { getDecisionsLedgerPath } from '../../utils/project-paths.js';

/**
* @devflow-design-decision D309
* Counts come from decisions-ledger.jsonl (the render source of truth), NOT
* the rendered decisions.md/pitfalls.md, so the HUD never couples to markdown
* format. Active-row semantics mirror render-decisions.cjs exactly: a row
* counts when anchor_id is set and decisions_status is absent or outside
* INACTIVE_STATUSES — so the numbers always equal the entries visible in the
* rendered files.
*/
const INACTIVE_STATUSES = new Set(['Deprecated', 'Superseded', 'Retired']);

interface LedgerCountRow {
type: 'decision' | 'pitfall';
anchor_id: string;
decisions_status?: string;
}

function isLedgerCountRow(val: unknown): val is LedgerCountRow {
if (typeof val !== 'object' || val === null) return false;
const o = val as Record<string, unknown>;
if (o.type !== 'decision' && o.type !== 'pitfall') return false;
if (typeof o.anchor_id !== 'string' || o.anchor_id.length === 0) return false;
return o.decisions_status === undefined || typeof o.decisions_status === 'string';
}

function isActive(row: LedgerCountRow): boolean {
if (!row.decisions_status) return true;
return !INACTIVE_STATUSES.has(row.decisions_status);
}

/**
* Read .devflow/decisions/decisions-ledger.jsonl and count active anchored
* rows by type. Returns null if the ledger is missing or holds no valid rows
* (graceful fallback). Exported for use by the main HUD entry point.
*/
export function gatherDecisionsCounts(cwd: string): DecisionsCountsData | null {
let content: string;
try {
content = fs.readFileSync(getDecisionsLedgerPath(cwd), 'utf-8');
} catch {
return null;
}

const counts: DecisionsCountsData = { decisions: 0, pitfalls: 0 };
let parsedAny = false;

for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line) continue;

let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
// Skip malformed lines — graceful
continue;
}

if (!isLedgerCountRow(parsed)) continue;
parsedAny = true;

if (!isActive(parsed)) continue;
if (parsed.type === 'decision') counts.decisions++;
else counts.pitfalls++;
}

return parsedAny ? counts : null;
}

/**
* HUD component: decisions/pitfalls counts.
* Shows how many active ADR/PF entries the project has accumulated.
* Returns null when no ledger exists or every entry is retired.
*/
export default async function decisionsCounts(
ctx: GatherContext,
): Promise<ComponentResult | null> {
const data = ctx.decisionsCounts;
if (!data) return null;

const { decisions, pitfalls } = data;
if (decisions + pitfalls === 0) return null;

const parts: string[] = [];
if (decisions > 0) parts.push(`${decisions} decision${decisions !== 1 ? 's' : ''}`);
if (pitfalls > 0) parts.push(`${pitfalls} pitfall${pitfalls !== 1 ? 's' : ''}`);

const raw = `Learning: ${parts.join(', ')}`;
return { text: dim(raw), raw };
}
1 change: 1 addition & 0 deletions src/cli/hud/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const HUD_COMPONENTS: readonly ComponentId[] = [
'usageQuota',
'todoProgress',
'configCounts',
'decisionsCounts',
'notifications',
];

Expand Down
8 changes: 8 additions & 0 deletions src/cli/hud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { gatherGitStatus } from './git.js';
import { parseTranscript } from './transcript.js';
import { persistSessionCost, aggregateCosts } from './cost-history.js';
import { gatherConfigCounts } from './components/config-counts.js';
import { gatherDecisionsCounts } from './components/decisions-counts.js';
import { getActiveNotification } from './notifications.js';
import { render } from './render.js';
import type { GatherContext, StdinData, UsageData } from './types.js';
Expand Down Expand Up @@ -86,6 +87,7 @@ async function run(): Promise<string> {
components.has('todoProgress') ||
components.has('configCounts');
const needsConfigCounts = components.has('configCounts');
const needsDecisionsCounts = components.has('decisionsCounts');
const needsNotifications = components.has('notifications');
const needsSessionCost = components.has('sessionCost');

Expand Down Expand Up @@ -114,6 +116,11 @@ async function run(): Promise<string> {
? gatherConfigCounts(cwd)
: null;

// Decisions/pitfalls counts (fast, synchronous filesystem read)
const decisionsCountsData = needsDecisionsCounts
? gatherDecisionsCounts(cwd)
: null;

// D24: Notification data (fast, synchronous filesystem read)
const notificationsData = needsNotifications
? getActiveNotification(cwd)
Expand All @@ -140,6 +147,7 @@ async function run(): Promise<string> {
transcript,
usage,
configCounts: configCountsData,
decisionsCounts: decisionsCountsData,
notifications: notificationsData,
costHistory,
config: { ...config, components: resolved } as GatherContext['config'],
Expand Down
28 changes: 8 additions & 20 deletions src/cli/hud/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import sessionDuration from './components/session-duration.js';
import usageQuota from './components/usage-quota.js';
import todoProgress from './components/todo-progress.js';
import configCounts from './components/config-counts.js';
import decisionsCounts from './components/decisions-counts.js';
import sessionCost from './components/session-cost.js';
import releaseInfo from './components/release-info.js';
import worktreeCount from './components/worktree-count.js';
Expand All @@ -34,6 +35,7 @@ const COMPONENT_MAP: Record<ComponentId, ComponentFn> = {
usageQuota,
todoProgress,
configCounts,
decisionsCounts,
sessionCost,
releaseInfo,
worktreeCount,
Expand All @@ -43,17 +45,12 @@ const COMPONENT_MAP: Record<ComponentId, ComponentFn> = {
/**
* Line groupings for smart layout.
* Components are assigned to lines and only rendered if enabled.
* null entries denote section breaks (blank line between sections).
*/
const LINE_GROUPS: (ComponentId[] | null)[] = [
// Section 1: Info (3 lines)
const LINE_GROUPS: ComponentId[][] = [
['directory', 'gitBranch', 'gitAheadBehind', 'releaseInfo', 'worktreeCount', 'diffStats'],
['contextUsage', 'usageQuota'],
['contextUsage', 'usageQuota', 'todoProgress'],
['model', 'configCounts', 'sessionCost'],
// --- section break ---
null,
// Section 2: Activity
['todoProgress'],
['decisionsCounts'],
['notifications'],
['versionBadge'],
];
Expand All @@ -62,7 +59,8 @@ const SEPARATOR = dim(' \u00B7 ');

/**
* Render all enabled components into a multi-line HUD string.
* Components that return null are excluded. Empty lines are skipped.
* Components that return null are excluded. Lines with no rendered
* components are skipped.
*/
export async function render(ctx: GatherContext): Promise<string> {
const enabled = new Set(ctx.config.components);
Expand All @@ -87,25 +85,15 @@ export async function render(ctx: GatherContext): Promise<string> {

await Promise.all(promises);

// Assemble lines using smart layout with section breaks
// Assemble lines using smart layout
const lines: string[] = [];
let pendingBreak = false;

for (const entry of LINE_GROUPS) {
if (entry === null) {
if (lines.length > 0) pendingBreak = true;
continue;
}

const lineResults = entry
.filter((id) => enabled.has(id) && results.has(id))
.map((id) => results.get(id)!);

if (lineResults.length > 0) {
if (pendingBreak) {
lines.push('');
pendingBreak = false;
}
// Separate multi-line results (containing newlines) from single-line
const singleLine: string[] = [];
for (const r of lineResults) {
Expand Down
10 changes: 10 additions & 0 deletions src/cli/hud/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ComponentId =
| 'usageQuota'
| 'todoProgress'
| 'configCounts'
| 'decisionsCounts'
| 'sessionCost'
| 'releaseInfo'
| 'worktreeCount'
Expand Down Expand Up @@ -114,6 +115,14 @@ export interface ConfigCountsData {
hooks: number;
}

/**
* Decisions/pitfalls counts data for the decisionsCounts component.
*/
export interface DecisionsCountsData {
decisions: number;
pitfalls: number;
}

/**
* D24: Notification data for the HUD notifications component.
*/
Expand All @@ -134,6 +143,7 @@ export interface GatherContext {
transcript: TranscriptData | null;
usage: UsageData | null;
configCounts: ConfigCountsData | null;
decisionsCounts: DecisionsCountsData | null;
notifications?: NotificationData | null;
costHistory: CostAggregation | null;
config: HudConfig & { components: ComponentId[] };
Expand Down
1 change: 1 addition & 0 deletions tests/hud-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeCtx(overrides: Partial<GatherContext> = {}): GatherContext {
transcript: null,
usage: null,
configCounts: null,
decisionsCounts: null,
costHistory: null,
config: { enabled: true, detail: false, components: [] },
devflowDir: '/test/.devflow',
Expand Down
Loading
Loading