- 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>
186 lines
6.3 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
},
|
|
)
|