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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1dde915540
commit
b1d7aed9f4
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user