Files
Jonathan KirkwoodandClaude Fable 5 b1d7aed9f4 Implement BDEF v1.1 grading: scoring core, per-deck pipeline, ledger, dashboard, StartOS layer
- Deterministic scoring.py (quant 60 / qual 40 / flags -15, profitability heaviest)
- Per-company JSON ledger with forecast-target chaining deck N-1 -> N
- Single-shot sandbox agent with guided-JSON fallback ladder (no tool loop)
- Portfolio dashboard with sparklines, KPI hit rates, BDEF category bars
- 48 unit tests green; endpoints smoke-tested; npm check+build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:15:12 -05:00

186 lines
6.3 KiB
TypeScript

import { sdk } from '../sdk'
import { configFile } from '../file-models/config'
const { InputSpec, Value, List } = sdk
const inputSpec = InputSpec.of({
companies: Value.list(
List.obj(
{
name: 'Portfolio Companies',
description:
'One entry per portfolio company. The SLUG is the folder you drop its ' +
'decks into (inbox/<slug>/2026-Q2-deck.pdf) and the key its running ' +
'scorecard ledger lives under. Pinned targets are the KPIs the scorer ' +
'holds the company to even when a deck goes quiet about them — pin the ' +
'profitability thresholds especially, since they carry the heaviest ' +
'weight (30 of the quant 60).',
default: [],
minLength: 0,
maxLength: 64,
},
{
uniqueBy: 'slug',
displayAs: '{{slug}}',
spec: InputSpec.of({
slug: Value.text({
name: 'Slug',
description:
'Folder name under the inbox and the ledger key. Stable — do not ' +
'rename once decks have been graded.',
required: true,
default: null,
placeholder: 'acme-widgets',
patterns: [
{ regex: '^[a-z0-9][a-z0-9-]{0,40}$',
description: 'Lowercase letters, numbers, dashes (max 41 chars).' },
],
}),
name: Value.text({
name: 'Display Name (optional)',
description: 'Shown on the dashboard and scorecards. Empty = the slug.',
required: false,
default: null,
placeholder: 'Acme Widgets, Inc.',
}),
kpiAliases: Value.textarea({
name: 'KPI Aliases (optional)',
description:
'One line per KPI: canonical=alias1;alias2 — maps the names a deck ' +
'uses onto the canonical KPI name, so "Adj. EBITDA" and "EBITDA ' +
'(adj)" both land on the same pinned target.',
required: false,
default: null,
minRows: 2,
maxRows: 12,
placeholder: 'ebitda=Adj. EBITDA;EBITDA (adj)\nmrr=Monthly Recurring Revenue;MRR',
}),
pinnedTargets: Value.list(
List.obj(
{
name: 'Pinned KPI Targets',
description:
'Targets graded every quarter regardless of what the deck ' +
'chooses to show. Mark profitability KPIs (EBITDA, net margin, ' +
'FCF...) so they score in the heavier profitability bucket.',
default: [],
minLength: 0,
maxLength: 32,
},
{
uniqueBy: 'kpi',
displayAs: '{{kpi}} → {{target}}',
spec: InputSpec.of({
kpi: Value.text({
name: 'KPI Name',
description: 'Canonical KPI name (see the aliases field above).',
required: true,
default: null,
placeholder: 'ebitda',
}),
target: Value.number({
name: 'Target',
description: 'The numeric target for the period.',
required: true,
default: null,
integer: false,
}),
unit: Value.text({
name: 'Unit (optional)',
description: 'For display only, e.g. "USD", "%", "customers".',
required: false,
default: null,
placeholder: 'USD',
}),
direction: Value.select({
name: 'Direction',
description: 'Whether hitting the target means being at-or-above it (revenue) or at-or-below it (churn, burn).',
default: 'gte',
values: {
gte: 'At or above target',
lte: 'At or below target',
},
}),
profitability: Value.toggle({
name: 'Profitability KPI',
description: 'Score this KPI in the profitability bucket (heaviest weight) instead of the general KPI bucket.',
default: false,
}),
}),
},
),
),
}),
},
),
),
})
export const configureCompanies = sdk.Action.withInput(
'configure-companies',
async ({ effects }) => ({
name: 'Configure Companies',
description: 'Define the portfolio companies, their inbox slugs, KPI aliases, and pinned KPI targets.',
warning: null,
allowedStatuses: 'any',
group: 'Grading',
visibility: 'enabled',
}),
inputSpec,
async ({ effects }) => {
const cfg = await configFile.read().const(effects)
if (!cfg) return {}
return {
companies: cfg.companies.map((c) => ({
slug: c.slug,
name: c.name || undefined,
kpiAliases: c.kpiAliases || undefined,
pinnedTargets: c.pinnedTargets.map((t) => ({
kpi: t.kpi,
target: t.target,
unit: t.unit || undefined,
direction: t.direction,
profitability: t.profitability,
})),
})),
}
},
async ({ effects, input }) => {
await configFile.merge(effects, {
companies: input.companies.map((c) => ({
slug: c.slug,
name: c.name ?? '',
kpiAliases: c.kpiAliases ?? '',
pinnedTargets: c.pinnedTargets.map((t) => ({
kpi: t.kpi,
target: t.target,
unit: t.unit ?? '',
direction: t.direction,
profitability: t.profitability,
})),
})),
})
return {
version: '1',
title: 'Companies Configured',
message:
'Saved ' + input.companies.length + ' company(ies). Drop each company\'s ' +
'decks into inbox/<slug>/ (e.g. inbox/' +
(input.companies[0]?.slug || 'acme-widgets') +
'/2026-Q2-deck.pdf), then run "Grade Decks".',
result: {
type: 'single',
value: input.companies.map((c) => c.slug).join(', ') || '(none)',
copyable: false,
qr: false,
masked: false,
},
}
},
)