From 8a53de56b68208965e943fa9de538ca6cec5e526 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sat, 4 Jul 2026 09:35:07 +0300 Subject: [PATCH 1/2] feat(hud): restore decisions/pitfalls counts segment Bring back the learning-counts HUD line removed with the learning pipeline (59db82e), rebuilt on the decisions ledger: counts active anchored rows from decisions-ledger.jsonl, mirroring the render-decisions.cjs active-row semantics so the numbers always match the rendered decisions.md/pitfalls.md (D309). Renders as a dim 'Learning: N decisions, M pitfalls' line in the Activity section, hidden when the ledger is absent or every entry is retired. --- src/cli/hud/components/decisions-counts.ts | 95 ++++++++++++ src/cli/hud/config.ts | 1 + src/cli/hud/index.ts | 8 ++ src/cli/hud/render.ts | 3 + src/cli/hud/types.ts | 10 ++ tests/hud-components.test.ts | 1 + tests/hud-decisions-counts.test.ts | 159 +++++++++++++++++++++ tests/hud-render.test.ts | 5 +- 8 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/cli/hud/components/decisions-counts.ts create mode 100644 tests/hud-decisions-counts.test.ts diff --git a/src/cli/hud/components/decisions-counts.ts b/src/cli/hud/components/decisions-counts.ts new file mode 100644 index 00000000..db4647ec --- /dev/null +++ b/src/cli/hud/components/decisions-counts.ts @@ -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; + 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 { + 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 }; +} diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts index 4af50df2..fb4f94fc 100644 --- a/src/cli/hud/config.ts +++ b/src/cli/hud/config.ts @@ -22,6 +22,7 @@ export const HUD_COMPONENTS: readonly ComponentId[] = [ 'usageQuota', 'todoProgress', 'configCounts', + 'decisionsCounts', 'notifications', ]; diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index f7586079..285d2843 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -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'; @@ -86,6 +87,7 @@ async function run(): Promise { 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'); @@ -114,6 +116,11 @@ async function run(): Promise { ? 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) @@ -140,6 +147,7 @@ async function run(): Promise { transcript, usage, configCounts: configCountsData, + decisionsCounts: decisionsCountsData, notifications: notificationsData, costHistory, config: { ...config, components: resolved } as GatherContext['config'], diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts index 8e811a41..f9db2ded 100644 --- a/src/cli/hud/render.ts +++ b/src/cli/hud/render.ts @@ -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'; @@ -34,6 +35,7 @@ const COMPONENT_MAP: Record = { usageQuota, todoProgress, configCounts, + decisionsCounts, sessionCost, releaseInfo, worktreeCount, @@ -54,6 +56,7 @@ const LINE_GROUPS: (ComponentId[] | null)[] = [ null, // Section 2: Activity ['todoProgress'], + ['decisionsCounts'], ['notifications'], ['versionBadge'], ]; diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index 11fbda31..161d0cd7 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -33,6 +33,7 @@ export type ComponentId = | 'usageQuota' | 'todoProgress' | 'configCounts' + | 'decisionsCounts' | 'sessionCost' | 'releaseInfo' | 'worktreeCount' @@ -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. */ @@ -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[] }; diff --git a/tests/hud-components.test.ts b/tests/hud-components.test.ts index f01d2fdb..271a6234 100644 --- a/tests/hud-components.test.ts +++ b/tests/hud-components.test.ts @@ -24,6 +24,7 @@ function makeCtx(overrides: Partial = {}): GatherContext { transcript: null, usage: null, configCounts: null, + decisionsCounts: null, costHistory: null, config: { enabled: true, detail: false, components: [] }, devflowDir: '/test/.devflow', diff --git a/tests/hud-decisions-counts.test.ts b/tests/hud-decisions-counts.test.ts new file mode 100644 index 00000000..de160f5a --- /dev/null +++ b/tests/hud-decisions-counts.test.ts @@ -0,0 +1,159 @@ +// tests/hud-decisions-counts.test.ts +// Tests for the HUD decisions/pitfalls counts component (D309). +// Validates active-row counting from decisions-ledger.jsonl, inactive-status +// exclusion, and graceful fallback when the ledger is missing or unreadable. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import decisionsCounts, { + gatherDecisionsCounts, +} from '../src/cli/hud/components/decisions-counts.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; +import type { DecisionsCountsData, GatherContext } from '../src/cli/hud/types.js'; + +// Helper: build a minimal ledger JSONL row with the given fields +function makeRow(type: string, extra: Record = {}): string { + return JSON.stringify({ + id: `obs_${Math.random().toString(36).slice(2)}`, + type, + pattern: 'test pattern', + details: 'test details', + anchor_id: type === 'pitfall' ? 'PF-001' : 'ADR-001', + decisions_status: 'Accepted', + ...extra, + }); +} + +function makeCtx(data: DecisionsCountsData | null): GatherContext { + return { + stdin: {}, + git: null, + transcript: null, + usage: null, + configCounts: null, + decisionsCounts: data, + costHistory: null, + config: { enabled: true, detail: false, components: [] }, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + }; +} + +describe('gatherDecisionsCounts', () => { + let tmpDir: string; + let ledgerPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hud-decisions-counts-')); + fs.mkdirSync(path.join(tmpDir, '.devflow', 'decisions'), { recursive: true }); + ledgerPath = path.join(tmpDir, '.devflow', 'decisions', 'decisions-ledger.jsonl'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('counts active rows by type', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001' }), + makeRow('decision', { anchor_id: 'ADR-002' }), + makeRow('decision', { anchor_id: 'ADR-003' }), + makeRow('pitfall', { anchor_id: 'PF-001' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 3, pitfalls: 1 }); + }); + + it('treats absent decisions_status and Active as active', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: undefined }), + makeRow('decision', { anchor_id: 'ADR-002', decisions_status: 'Active' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 2, pitfalls: 0 }); + }); + + it('excludes Deprecated, Superseded, and Retired rows', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: 'Deprecated' }), + makeRow('decision', { anchor_id: 'ADR-002', decisions_status: 'Superseded' }), + makeRow('pitfall', { anchor_id: 'PF-001', decisions_status: 'Retired' }), + makeRow('pitfall', { anchor_id: 'PF-002' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 1 }); + }); + + it('skips rows without anchor_id', () => { + const lines = [ + makeRow('decision', { anchor_id: undefined }), + makeRow('decision', { anchor_id: 'ADR-001' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 1, pitfalls: 0 }); + }); + + it('skips malformed JSON lines', () => { + const content = `not json at all\n${makeRow('pitfall', { anchor_id: 'PF-001' })}\n{truncated\n`; + fs.writeFileSync(ledgerPath, content); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 1 }); + }); + + it('returns null when the ledger file is missing', () => { + expect(gatherDecisionsCounts(tmpDir)).toBeNull(); + }); + + it('returns null when the ledger holds no valid rows', () => { + fs.writeFileSync(ledgerPath, 'garbage\n\n{"type":"decision"}\n'); + + expect(gatherDecisionsCounts(tmpDir)).toBeNull(); + }); + + it('returns zero counts (not null) when every row is inactive', () => { + fs.writeFileSync( + ledgerPath, + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: 'Retired' }) + '\n', + ); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 0 }); + }); +}); + +describe('decisionsCounts component', () => { + it('returns null when no data was gathered', async () => { + expect(await decisionsCounts(makeCtx(null))).toBeNull(); + }); + + it('returns null when counts are all zero', async () => { + expect(await decisionsCounts(makeCtx({ decisions: 0, pitfalls: 0 }))).toBeNull(); + }); + + it('renders decisions and pitfalls with singular/plural forms', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 1, pitfalls: 2 })); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Learning: 1 decision, 2 pitfalls'); + }); + + it('omits zero-count parts', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 3, pitfalls: 0 })); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Learning: 3 decisions'); + }); + + it('dims the rendered text without altering content', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 2, pitfalls: 1 })); + + expect(result).not.toBeNull(); + expect(stripAnsi(result!.text)).toBe(result!.raw); + }); +}); diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index 00952af7..4b805b8f 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -37,6 +37,7 @@ function makeCtx( transcript: null, usage: null, configCounts: null, + decisionsCounts: null, costHistory: null, config: { enabled: true, @@ -203,7 +204,7 @@ describe('config', () => { expect(resolveComponents(config)).toEqual(['versionBadge']); }); - it('HUD_COMPONENTS has 14 components (sessionDuration retained but omitted from defaults; learningCounts removed)', () => { - expect(HUD_COMPONENTS).toHaveLength(14); + it('HUD_COMPONENTS has 15 components (sessionDuration retained but omitted from defaults)', () => { + expect(HUD_COMPONENTS).toHaveLength(15); }); }); From 160ff92113bb99f5ae06bbb69c0d37b802b70fe5 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sat, 4 Jul 2026 09:48:24 +0300 Subject: [PATCH 2/2] feat(hud): inline todos on capacity line, drop section break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todos render at the end of the capacity line (context · quota · todos) and the Info/Activity blank-line break is removed — the HUD is one compact block. LINE_GROUPS no longer carries null section breaks, so the pendingBreak machinery goes with it. --- src/cli/hud/render.ts | 25 +++++-------------------- tests/hud-render.test.ts | 35 +++++++++++------------------------ 2 files changed, 16 insertions(+), 44 deletions(-) diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts index f9db2ded..975b6fec 100644 --- a/src/cli/hud/render.ts +++ b/src/cli/hud/render.ts @@ -45,17 +45,11 @@ const COMPONENT_MAP: Record = { /** * 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'], @@ -65,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 { const enabled = new Set(ctx.config.components); @@ -90,25 +85,15 @@ export async function render(ctx: GatherContext): Promise { 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) { diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index 4b805b8f..8a645262 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -87,7 +87,7 @@ describe('render', () => { expect(raw).toContain('30%'); }); - it('shows activity section with todos and config counts', async () => { + it('renders todos at the end of the capacity line', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, @@ -105,17 +105,18 @@ describe('render', () => { }, }); const output = await render(ctx); - const lines = output.split('\n').filter((l) => l.length > 0); + const lines = output.split('\n').map((l) => stripAnsi(l)); - // 3 info lines + blank + todo line = 4+ - expect(lines.length).toBeGreaterThanOrEqual(4); + expect(lines).toHaveLength(3); + const capacityLine = lines.find((l) => l.includes('Context')); + expect(capacityLine).toBeDefined(); + expect(capacityLine).toContain('5h'); + expect(capacityLine!.endsWith('2/4 todos')).toBe(true); const raw = stripAnsi(output); - expect(raw).toContain('2/4 todos'); expect(raw).toContain('2 CLAUDE.md'); expect(raw).toContain('3 rules'); expect(raw).toContain('1 MCPs'); expect(raw).toContain('4 hooks'); - expect(raw).toContain('5h'); }); it('components that return null are excluded', async () => { @@ -141,7 +142,7 @@ describe('render', () => { expect(raw).not.toContain('feat/test'); }); - it('inserts blank line between info and activity sections', async () => { + it('emits no blank lines between groups', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, @@ -151,28 +152,14 @@ describe('render', () => { todos: { completed: 1, total: 3 }, skills: [], }, + decisionsCounts: { decisions: 3, pitfalls: 1 }, }); const output = await render(ctx); const lines = output.split('\n'); - // Should contain an empty line between info and activity sections - expect(lines).toContain(''); - // Empty line should be between non-empty lines - const emptyIdx = lines.indexOf(''); - expect(emptyIdx).toBeGreaterThan(0); - expect(emptyIdx).toBeLessThan(lines.length - 1); - }); - - it('no blank line when activity section is empty', async () => { - const ctx = makeCtx({ - sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, - }); - const output = await render(ctx); - const lines = output.split('\n'); - - // No empty lines — no activity components have data + expect(lines.length).toBeGreaterThanOrEqual(4); expect(lines.every((l) => l.length > 0)).toBe(true); + expect(stripAnsi(lines[lines.length - 1])).toBe('Learning: 3 decisions, 1 pitfall'); }); });