assets/rd_program_budget_template.md
# R&D Program Budget — Template
> Fill this before running `program_budget_planner.py`. Every number must travel with its
> assumptions. Capitalize-vs-expense calls route to a named finance owner — this is not the
> place to decide accounting treatment.
## 1. Program identification
- Program name:
- R&D area / profile: [pharma-rd | biotech | medtech | deep-tech | software-rd | university-lab]
- Number of periods + period label (month / quarter / year):
- Funding source(s):
## 2. F&A (indirect) basis
- F&A rate applied: ____%
- Rate type: [negotiated NICRA | de minimis 10% | internal assumption — FLAG IT]
- Fringe rate (loaded onto salaries before F&A): ____%
## 3. Work packages (per-period amounts)
| Work package | Category | F&A-eligible? | P1 | P2 | P3 | P4 |
|---|---|---|---|---|---|---|
| Personnel (FTEs) | personnel | yes | | | | |
| Consumables / supplies | supplies | yes | | | | |
| Capital equipment | capital_equipment | NO (MTDC-exempt) | | | | |
| Subaward / CRO (>$25k) | subaward_over_25k | NO (over $25k exempt) | | | | |
| Travel | travel | yes | | | | |
> Categories that are MTDC-exempt: capital_equipment, subaward_over_25k, tuition, patient_care.
## 4. Milestones (for burn/runway)
| Milestone | Periods from now | Cumulative cash needed |
|---|---|---|
| | | |
## 5. Capitalize-vs-expense candidates (for routing only)
| Cost item | Phase (research / development / software-development) | Standard (ifrs / usgaap) |
|---|---|---|
| | | |
## 6. Assumptions register
- F&A rate basis:
- Escalation assumption:
- Forward burn assumption (flat / ramp):
- Probability-of-success weighting (for any portfolio ROI):
## 7. Named owners
- R&D Finance Controller:
- External Auditor (if capitalization in play):
- Program Lead:
references/burn_and_portfolio.md
# Burn, Runway, and R&D Portfolio Management
Reference for burn/runway tracking and risk-adjusted portfolio decisions. Pairs with `burn_runway_tracker.py`.
## Burn and runway done honestly
**Burn rate** is cash spent per period; **runway** is cash-on-hand ÷ forward run-rate. The honest forward run-rate is the **trailing** (recent-weighted) burn, not the lifetime average — averages mask both an accelerating spend and a funded ramp. The tracker uses trailing burn and flags when trailing exceeds 115% of the lifetime average (an acceleration signal). Runway must be measured against **value-inflection milestones**: cash that runs out one month before analytical validation is materially worse than the same runway that clears it, because reaching the milestone changes the program's financing options and valuation.
## Stage-gate portfolio management
Robert Cooper's **Stage-Gate** model structures R&D as a sequence of stages separated by go/kill **gates**. Each gate is a real-options decision: spend the next tranche, or kill and redeploy. The discipline is that money is committed one stage at a time, against pre-defined criteria — not as a lump sum at kickoff. This is why milestone-vs-cash alignment is the core runway question.
## Risk-adjusted valuation
Raw NPV systematically overstates R&D value because it ignores attrition. **Risk-adjusted NPV (rNPV)** weights each phase's cash flows by the cumulative probability of success of reaching it — in drug development, the product of per-phase success rates (which compound to single-digit percentages from preclinical to approval). **Real-options** valuation goes further, pricing the optionality of being able to abandon. For portfolio ROI, always state whether the number is raw NPV or risk-adjusted; the difference is often an order of magnitude.
## Efficiency benchmarks
Startup/SaaS efficiency frameworks (a16z's burn multiple, Bessemer's efficiency score) translate to R&D portfolios as "value created per dollar burned." They are blunt but useful for cross-program comparison when paired with milestone progress.
## Sources
1. Cooper, R.G., *Winning at New Products: Creating Value Through Innovation*, 5th ed. (2017) — Stage-Gate.
2. Stewart, Allison & Johnson, *Putting a price on biotechnology* — Nature Biotechnology 2001 (rNPV in drug development).
3. Trigeorgis, L., *Real Options: Managerial Flexibility and Strategy in Resource Allocation* (MIT Press).
4. DiMasi, Grabowski & Hansen, *Innovation in the pharmaceutical industry: New estimates of R&D costs* — J Health Econ 2016 (attrition / phase success rates).
5. a16z, *The burn multiple* and Bessemer State of the Cloud efficiency benchmarks.
6. Chan & Thornhill, *R&D portfolio management* — R&D Management literature.
references/indirect_rate_modeling.md
# Indirect (F&A) Rate Modeling
Deep reference for the F&A rate — the single most error-prone input in an R&D budget. Pairs with `program_budget_planner.py`.
## What the F&A rate actually is
The F&A rate recovers shared costs that cannot be traced to a single program. It is composed of two pools:
- **Facilities** — depreciation on buildings and equipment, interest on facility debt, operations & maintenance, library, utilities.
- **Administration** — general administration, departmental administration, sponsored-projects administration, student services (in universities).
The rate is computed as (indirect pool ÷ allocation base) and applied to that base on each program.
## The base matters as much as the rate
A 55% rate on a $1M total budget is *not* $550k of F&A — because the rate applies only to the **MTDC base**, which excludes:
- Capital equipment (typically items > $5,000 with > 1-year life)
- The portion of **each** subaward exceeding $25,000 (the first $25k is in the base; the rest is exempt)
- Tuition remission
- Patient-care costs
- Rental of off-site facilities, scholarships, participant support
So a budget heavy in equipment and large subawards has a much smaller F&A base than its headline total. The planner models this exclusion explicitly.
## Negotiated vs de minimis
- **NICRA** — the Negotiated Indirect Cost Rate Agreement, established with a cognizant federal agency. This is the authoritative rate for federally funded work.
- **De minimis 10%** — under 2 CFR 200.414(f), an entity that has never had a negotiated rate may elect a flat 10% of MTDC. Simpler, almost always lower than a negotiated research rate.
## Fringe and the loading stack
Personnel costs load in layers: base salary → **fringe** (benefits, often 25-35%) → then F&A applies to salary+fringe (both are in the MTDC base). Modeling fringe separately from F&A avoids double counting or under-recovery.
## Sources
1. 2 CFR 200.414, *Indirect (F&A) costs*, and Appendix III (IHEs) / Appendix IV (nonprofits).
2. 2 CFR 200.1, definition of *Modified Total Direct Cost (MTDC)*.
3. NIH Grants Policy Statement, indirect-cost chapter; DHHS Cost Allocation Services NICRA guidance.
4. Cost Accounting Standards Board, 48 CFR 9904 (CAS 410, 418 on allocation).
5. COGR (Council on Governmental Relations), *Indirect Cost / F&A* primers and white papers.
6. Federal Demonstration Partnership materials on subaward and MTDC treatment.
references/rd_program_finance_canon.md
# R&D Program Finance Canon
Reference for the accounting and budgeting rules that govern internal R&D spend. Pairs with `program_budget_planner.py` and `capex_vs_opex_router.py`.
## The central question: research vs development
The accounting treatment of R&D hinges on a phase distinction that the two major frameworks handle differently:
- **IFRS (IAS 38)** — *Research* costs are always **expensed**. *Development* costs **must be capitalized** once all six conditions are met: (1) technical feasibility, (2) intention to complete, (3) ability to use or sell, (4) probable future economic benefit, (5) adequate resources to complete, (6) reliable measurement of expenditure. This is not optional under IFRS — if the criteria are met, capitalization is required.
- **US GAAP (ASC 730)** — R&D is **expensed as incurred**, full stop, with narrow exceptions. The main exception is software: **ASC 985-20** (software to be sold) capitalizes costs after *technological feasibility*; **ASC 350-40** (internal-use software) capitalizes during the application-development stage.
This divergence is why the router takes a `--standard {ifrs,usgaap}` flag: the same cost item can be EXPENSE under US GAAP and CAPITALIZE-CANDIDATE under IFRS.
## F&A / indirect cost (the budgeting half)
Direct costs are traceable to the program (personnel, supplies). **Facilities & Administrative (F&A)**, a.k.a. indirect or overhead, covers shared costs (building, utilities, administration). For federally funded research, F&A is governed by **Uniform Guidance (2 CFR 200)**: organizations negotiate a rate (the NICRA — Negotiated Indirect Cost Rate Agreement) or use the **de minimis 10%** rate. F&A applies to the **Modified Total Direct Cost (MTDC)** base, which *excludes* capital equipment, the portion of each subaward over $25,000, tuition, and patient-care costs. The budget planner enforces this MTDC exclusion.
## Why disclosure matters
A budget number is only as trustworthy as its rate basis and escalation assumption. Two budgets for the same program can differ 40%+ purely on the F&A rate and the base. Every output of the planner ships an assumptions block for exactly this reason.
## Sources
1. IAS 38, *Intangible Assets* — IASB (research vs development, paragraphs 54-67).
2. FASB ASC 730, *Research and Development*; ASC 985-20, *Software — Costs of Software to Be Sold, Leased, or Marketed*; ASC 350-40, *Internal-Use Software*.
3. 2 CFR 200 (Uniform Guidance), Subpart E — Cost Principles, esp. §200.414 (Indirect F&A costs) and the MTDC definition (§200.1).
4. Cost Accounting Standards (CAS), 48 CFR 9904 — for federally funded R&D contractors.
5. KPMG / PwC / Deloitte IFRS-vs-US-GAAP comparison guides (R&D and intangibles chapters).
6. AICPA Accounting & Valuation Guide, *Research and Development*.
scripts/ar_evaluator.py
#!/usr/bin/env python3
"""ar_evaluator.py - Autoresearch evaluator for the research-finance skill (OPT-IN).
Stdlib-only. The ISOLATED bridge to engineering/autoresearch-agent. It does NOT call
autoresearch; it is the ground-truth evaluator an autoresearch loop runs after editing
the target ledger/budget. It reads a ledger JSON, computes runway via burn_runway_tracker,
and prints ONE metric line:
runway_months: <float> (higher is better)
Optimize a program plan to maximize runway (e.g., resequencing spend) while the agent
edits the target. The user opts in explicitly:
/ar:setup --domain custom --name extend-runway \\
--target ledger.json --eval "python3 ar_evaluator.py --target ledger.json" \\
--metric runway_months --direction higher
Direct use:
python3 ar_evaluator.py --sample
python3 ar_evaluator.py --target ledger.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config_loader as cfg # noqa: E402
import burn_runway_tracker as brt # noqa: E402
METRIC = "runway_months"
def main(argv: list[str] | None = None) -> int:
c = cfg.load_config()
p = argparse.ArgumentParser(description="Autoresearch evaluator: R&D program runway in months.")
p.add_argument("--target", help="path to ledger JSON (or env AR_TARGET)")
p.add_argument("--threshold-months", type=float, default=None)
p.add_argument("--sample", action="store_true")
args = p.parse_args(argv)
threshold = args.threshold_months if args.threshold_months is not None \
else c.get("runway_threshold_months", 6)
if args.sample:
data = brt.SAMPLE
else:
target = args.target or os.environ.get("AR_TARGET")
if not target:
print("error: provide --target <ledger.json> or set AR_TARGET", file=sys.stderr)
return 2
try:
with open(target) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"{METRIC}: N/A")
print(f"error: {e}", file=sys.stderr)
return 1
try:
result = brt.analyze(data, threshold)
except ValueError as e:
print(f"{METRIC}: N/A")
print(f"error: {e}", file=sys.stderr)
return 1
print(f"{METRIC}: {result['runway_months_approx']}")
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/burn_runway_tracker.py
#!/usr/bin/env python3
"""burn_runway_tracker.py - Compute R&D program burn, runway, and milestone-vs-cash alignment.
Stdlib-only. Deterministic. NO LLM calls. Surfaces the assumption behind every number.
Given cash-on-hand, a period ledger of actual spend, and upcoming milestones (each with a
period index and the cash needed to reach it), computes:
- average + trailing burn rate
- runway in periods and (approx) months
- whether each value-inflection milestone is reachable before cash runs out
Usage:
python3 burn_runway_tracker.py --sample
python3 burn_runway_tracker.py --input ledger.json --threshold-months 6
python3 burn_runway_tracker.py --input ledger.json --output json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import config_loader as _cfg
except ImportError: # pragma: no cover
_cfg = None
SAMPLE = {
"program": "Next-Gen Assay Platform",
"cash_on_hand": 3200000,
"period_label": "month",
"actual_spend": [285000, 305000, 330000, 360000],
"milestones": [
{"name": "Analytical validation", "period_from_now": 3, "cumulative_cash_needed": 1000000},
{"name": "First-in-human readiness", "period_from_now": 9, "cumulative_cash_needed": 3400000},
],
}
def analyze(data: dict, threshold_months: float) -> dict:
spend = [float(x) for x in data.get("actual_spend", [])]
cash = float(data.get("cash_on_hand", 0.0))
label = data.get("period_label", "month")
months_per_period = 1.0 if label == "month" else (3.0 if label == "quarter" else 1.0)
if not spend:
raise ValueError("actual_spend must contain at least one period.")
avg_burn = sum(spend) / len(spend)
trailing_n = min(3, len(spend))
trailing_burn = sum(spend[-trailing_n:]) / trailing_n
# Use trailing burn (more recent) as the forward run-rate.
run_rate = trailing_burn if trailing_burn > 0 else avg_burn
runway_periods = cash / run_rate if run_rate > 0 else float("inf")
runway_months = runway_periods * months_per_period
milestones_out = []
for m in data.get("milestones", []):
needed = float(m.get("cumulative_cash_needed", 0.0))
period_from_now = float(m.get("period_from_now", 0))
reachable_cash = needed <= cash
reachable_time = period_from_now <= runway_periods
verdict = "REACHABLE" if (reachable_cash and reachable_time) else "AT-RISK"
milestones_out.append({
"name": m.get("name", "UNNAMED"),
"period_from_now": period_from_now,
"cumulative_cash_needed": needed,
"cash_covers": reachable_cash,
"runway_covers_timing": reachable_time,
"verdict": verdict,
})
flags = []
if runway_months < threshold_months:
flags.append(f"RUNWAY BELOW THRESHOLD: {runway_months:.1f} months < {threshold_months} month threshold.")
if any(m["verdict"] == "AT-RISK" for m in milestones_out):
flags.append("At least one value-inflection milestone is AT-RISK on current burn.")
if trailing_burn > avg_burn * 1.15:
flags.append(f"Burn accelerating: trailing burn ${trailing_burn:,.0f} > 115% of average ${avg_burn:,.0f}.")
return {
"program": data.get("program", "UNSPECIFIED"),
"cash_on_hand": cash,
"average_burn_per_period": round(avg_burn, 2),
"trailing_burn_per_period": round(trailing_burn, 2),
"forward_run_rate_used": round(run_rate, 2),
"runway_periods": round(runway_periods, 2),
"runway_months_approx": round(runway_months, 1),
"milestones": milestones_out,
"flags": flags,
"assumptions": [
f"Forward run-rate = trailing {trailing_n}-period burn (recent-weighted, not lifetime average).",
f"Period label '{label}' => {months_per_period} month(s) per period.",
"Runway assumes flat forward burn; a funded ramp or hiring plan changes this.",
"Milestone cash needs are cumulative-from-now as supplied; verify against the program budget.",
],
}
def _render_human(r: dict) -> str:
lines = [f"Burn & Runway: {r['program']}", "",
f"Cash on hand: ${r['cash_on_hand']:,.0f}",
f"Average burn/period: ${r['average_burn_per_period']:,.0f}",
f"Trailing burn/period: ${r['trailing_burn_per_period']:,.0f}",
f"Forward run-rate used: ${r['forward_run_rate_used']:,.0f}",
f"Runway: {r['runway_periods']} periods (~{r['runway_months_approx']} months)",
""]
lines.append("Milestones:")
for m in r["milestones"]:
lines.append(f" [{m['verdict']}] {m['name']} (+{m['period_from_now']:.0f} periods, "
f"needs ${m['cumulative_cash_needed']:,.0f})")
lines.append("")
if r["flags"]:
lines.append("Flags:")
for f in r["flags"]:
lines.append(f" ! {f}")
lines.append("")
lines.append("Assumptions:")
for a in r["assumptions"]:
lines.append(f" - {a}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Compute R&D program burn, runway, and milestone alignment.")
p.add_argument("--input", help="Path to JSON ledger")
p.add_argument("--threshold-months", type=float, default=None, help="runway alert threshold (months)")
p.add_argument("--output", choices=["human", "json"], default="human")
p.add_argument("--sample", action="store_true", help="use the embedded sample")
args = p.parse_args(argv)
conf = _cfg.load_config() if _cfg else {}
threshold = args.threshold_months if args.threshold_months is not None \
else float(conf.get("runway_threshold_months", 6.0))
data = SAMPLE if (args.sample or not args.input) else json.load(open(args.input))
try:
result = analyze(data, threshold)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(_render_human(result))
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/capex_vs_opex_router.py
#!/usr/bin/env python3
"""capex_vs_opex_router.py - Decision-SUPPORT for R&D capitalize-vs-expense treatment.
Stdlib-only. Deterministic. NO LLM calls. This tool NEVER books an entry and NEVER
auto-decides accounting treatment. It scores each cost item against capitalization
criteria and ROUTES it to a named finance owner for the actual determination.
Criteria reflect IAS 38 (development-phase capitalization test) and US GAAP ASC 730
(R&D expensed as incurred) / ASC 985-20 (internal-use & sold software). The six IAS 38
development-phase conditions:
1. technical feasibility established
2. intention to complete
3. ability to use or sell
4. probable future economic benefit
5. adequate resources to complete
6. reliable measurement of expenditure
Verdicts:
- CAPITALIZE-CANDIDATE (development phase, all criteria met) -> still routes to finance owner
- EXPENSE (research phase, or criteria not met)
- FINANCE-OWNER-REVIEW (ambiguous / partial criteria)
Usage:
python3 capex_vs_opex_router.py --sample
python3 capex_vs_opex_router.py --input costs.json --standard ifrs
python3 capex_vs_opex_router.py --input costs.json --standard usgaap --output json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import config_loader as _cfg
except ImportError: # pragma: no cover
_cfg = None
IAS38_CRITERIA = [
"technical_feasibility",
"intention_to_complete",
"ability_to_use_or_sell",
"probable_future_benefit",
"adequate_resources",
"reliable_measurement",
]
# Profiles only annotate context; they do not change the accounting test.
PROFILES = {
"pharma-rd": "Most drug R&D is expensed; capitalization rare pre-approval.",
"biotech": "Similar to pharma; pre-approval development typically expensed.",
"medtech": "Some development capitalizable post-feasibility under IFRS.",
"deep-tech": "Prototype-to-product transition is the key feasibility line.",
"software-rd": "ASC 985-20 / IAS 38: capitalize after technological feasibility / working model.",
"university-lab": "Grant-funded research almost always expensed per funder terms.",
}
SAMPLE = {
"standard": "ifrs",
"items": [
{
"name": "Exploratory target screening",
"phase": "research",
"criteria": {},
},
{
"name": "Pilot-line tooling for validated design",
"phase": "development",
"criteria": {
"technical_feasibility": True, "intention_to_complete": True,
"ability_to_use_or_sell": True, "probable_future_benefit": True,
"adequate_resources": True, "reliable_measurement": True,
},
},
{
"name": "Software build (post working-model, pre-release)",
"phase": "development",
"criteria": {
"technical_feasibility": True, "intention_to_complete": True,
"ability_to_use_or_sell": True, "probable_future_benefit": True,
"adequate_resources": False, "reliable_measurement": True,
},
},
],
}
def route_item(item: dict, standard: str) -> dict:
phase = (item.get("phase") or "").lower()
crit = item.get("criteria", {}) or {}
met = [c for c in IAS38_CRITERIA if crit.get(c)]
missing = [c for c in IAS38_CRITERIA if not crit.get(c)]
# US GAAP ASC 730: R&D expensed as incurred (software is the main exception via ASC 985-20).
if standard == "usgaap" and phase != "software-development":
verdict = "EXPENSE"
rationale = "ASC 730: R&D is expensed as incurred (non-software). Confirm software exceptions separately."
owner = "R&D Finance Controller"
elif phase == "research":
verdict = "EXPENSE"
rationale = "Research phase: cannot capitalize (IAS 38.54)."
owner = "R&D Finance Controller"
elif phase in ("development", "software-development") and not missing:
verdict = "CAPITALIZE-CANDIDATE"
rationale = "Development phase with all 6 IAS 38 criteria asserted. Routed for finance confirmation."
owner = "R&D Finance Controller + External Auditor sign-off"
else:
verdict = "FINANCE-OWNER-REVIEW"
rationale = f"Development phase but {len(missing)} criteria unmet/unstated: {', '.join(missing) or 'n/a'}."
owner = "R&D Finance Controller"
return {
"name": item.get("name", "UNNAMED"),
"phase": phase or "UNSPECIFIED",
"criteria_met": met,
"criteria_missing": missing,
"verdict": verdict,
"rationale": rationale,
"named_owner": owner,
}
def route(data: dict, standard: str, profile: str) -> dict:
if profile not in PROFILES:
raise ValueError(f"Unknown profile '{profile}'. Choose from {list(PROFILES)}.")
items = [route_item(i, standard) for i in data.get("items", [])]
return {
"standard": standard,
"profile": profile,
"profile_note": PROFILES[profile],
"items": items,
"disclaimer": "DECISION SUPPORT ONLY. This tool does not book entries or decide treatment. "
"A named finance owner (and auditor where required) makes the determination.",
}
def _render_human(r: dict) -> str:
lines = [f"Capitalize-vs-Expense routing (standard: {r['standard']}, profile: {r['profile']})",
f" {r['profile_note']}", ""]
for it in r["items"]:
lines.append(f"[{it['verdict']}] {it['name']} (phase: {it['phase']})")
lines.append(f" {it['rationale']}")
if it["criteria_missing"]:
lines.append(f" missing/unstated: {', '.join(it['criteria_missing'])}")
lines.append(f" -> route to: {it['named_owner']}")
lines.append("")
lines.append(f"!! {r['disclaimer']}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Route R&D costs to capitalize/expense/review (DECISION SUPPORT ONLY).")
p.add_argument("--input", help="Path to JSON with items[]")
p.add_argument("--standard", default=None, choices=["ifrs", "usgaap"],
help="overrides onboarding accounting_standard")
p.add_argument("--profile", default=None, choices=list(PROFILES),
help="overrides onboarding default_profile")
p.add_argument("--output", choices=["human", "json"], default="human")
p.add_argument("--sample", action="store_true", help="use the embedded sample")
args = p.parse_args(argv)
conf = _cfg.load_config() if _cfg else {}
profile = args.profile or conf.get("default_profile", "biotech")
cli_standard = args.standard or conf.get("accounting_standard", "ifrs")
data = SAMPLE if (args.sample or not args.input) else json.load(open(args.input))
standard = data.get("standard", cli_standard) if (args.sample or not args.input) else cli_standard
try:
result = route(data, standard, profile)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
finance_owner = conf.get("finance_owner")
if finance_owner:
for it in result["items"]:
it["named_owner"] = it["named_owner"].replace(
"R&D Finance Controller", f"R&D Finance Controller ({finance_owner})")
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(_render_human(result))
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/config_loader.py
#!/usr/bin/env python3
"""config_loader.py - Customization loader for the research-finance skill.
Stdlib-only. Importable from the skill's other scripts. Precedence (highest wins):
1. Project config: <cwd>/.research-ops/research-finance.json
2. Global config: ~/.config/research-ops/research-finance.json
3. Built-in DEFAULTS
Onboarding answers (written by onboard.py) live in these files; every tool in this
skill reads them so the user's customization applies automatically.
Set RESEARCH_OPS_NO_CONFIG=1 to ignore saved config.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
SKILL = "research-finance"
GLOBAL_CONFIG_DIR = Path.home() / ".config" / "research-ops"
GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / f"{SKILL}.json"
PROJECT_CONFIG_DIRNAME = ".research-ops"
DEFAULTS: dict[str, Any] = {
"version": 1,
"skill": SKILL,
"default_profile": "biotech",
"default_fa_rate": None, # None => use the profile's default F&A rate
"runway_threshold_months": 6,
"accounting_standard": "ifrs",
"finance_owner": None,
"setup_completed_at": None,
}
def project_config_path(cwd: Path | None = None) -> Path:
cwd = cwd or Path.cwd()
return cwd / PROJECT_CONFIG_DIRNAME / f"{SKILL}.json"
def _read_json(path: Path) -> dict[str, Any] | None:
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def load_config(cwd: Path | None = None) -> dict[str, Any]:
config = dict(DEFAULTS)
if os.environ.get("RESEARCH_OPS_NO_CONFIG") == "1":
return config
global_cfg = _read_json(GLOBAL_CONFIG_PATH)
if global_cfg:
config = _deep_merge(config, global_cfg)
project_cfg = _read_json(project_config_path(cwd))
if project_cfg:
config = _deep_merge(config, project_cfg)
return config
def setup_completed() -> bool:
cfg = _read_json(GLOBAL_CONFIG_PATH) or _read_json(project_config_path())
return bool(cfg and cfg.get("setup_completed_at"))
def write_config(config: dict[str, Any], scope: str = "global", cwd: Path | None = None) -> Path:
path = project_config_path(cwd) if scope == "project" else GLOBAL_CONFIG_PATH
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(config, f, indent=2, sort_keys=True)
return path
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=f"Inspect {SKILL} customization config.")
p.add_argument("--show", action="store_true", help="Print the effective config")
p.add_argument("--status", action="store_true", help="Print setup status + paths")
p.add_argument("--sample", action="store_true", help="Print the built-in defaults")
args = p.parse_args(argv)
if args.sample:
print(json.dumps(DEFAULTS, indent=2, sort_keys=True))
elif args.status:
print(json.dumps({
"skill": SKILL,
"global_config_path": str(GLOBAL_CONFIG_PATH),
"global_config_exists": GLOBAL_CONFIG_PATH.exists(),
"project_config_path": str(project_config_path()),
"project_config_exists": project_config_path().exists(),
"setup_completed": setup_completed(),
}, indent=2))
else:
print(json.dumps(load_config(), indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/onboard.py
#!/usr/bin/env python3
"""onboard.py - Onboarding questionnaire for the research-finance skill.
Stdlib-only. Asks the user a short set of questions BEFORE they build an R&D program
budget, then writes the answers to a customization config read by every tool in this
skill via config_loader.py. The answers become defaults for profile, F&A rate, runway
threshold, accounting standard, and the named finance owner printed on routing outputs.
Modes: --show | --defaults | --set key=value (repeatable) | --reset | --scope {global,project}
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config_loader as cfg # noqa: E402
NUMERIC_KEYS = {"default_fa_rate", "runway_threshold_months"}
QUESTIONS = [
("default_profile",
"1. What R&D area is this program?",
["pharma-rd", "biotech", "medtech", "deep-tech", "software-rd", "university-lab"], str),
("default_fa_rate",
"2. F&A / indirect rate as a fraction (e.g. 0.55), or blank to use the profile default?",
None, float),
("runway_threshold_months",
"3. Runway alert threshold in months (warn below this)?",
None, float),
("accounting_standard",
"4. Which accounting standard governs capitalize-vs-expense?",
["ifrs", "usgaap"], str),
("finance_owner",
"5. Named finance/controller owner who signs accounting treatment?",
None, str),
]
def _print_questions() -> None:
print(f"Onboarding questions — {cfg.SKILL}:\n")
for _k, prompt, choices, _c in QUESTIONS:
line = f" {prompt}"
if choices:
line += f" [{' / '.join(choices)}]"
print(line)
def run_interactive(config: dict) -> dict:
print(f"Onboarding — {cfg.SKILL}. Press Enter to keep the current/default value.\n")
for key, prompt, choices, caster in QUESTIONS:
suffix = f" [{'/'.join(choices)}]" if choices else ""
cur = f" (current: {config.get(key)})" if config.get(key) is not None else ""
raw = input(f"{prompt}{suffix}{cur}: ").strip()
if not raw:
continue
try:
config[key] = caster(raw)
except ValueError:
print(f" ! invalid value for {key}, keeping current")
return config
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=f"Onboarding for the {cfg.SKILL} skill.")
p.add_argument("--show", action="store_true")
p.add_argument("--defaults", action="store_true", help="write built-in defaults, no prompt")
p.add_argument("--set", action="append", default=[], metavar="key=value")
p.add_argument("--reset", action="store_true")
p.add_argument("--scope", choices=["global", "project"], default="global")
args = p.parse_args(argv)
if args.show:
_print_questions()
print("\nCurrent effective config:")
print(json.dumps(cfg.load_config(), indent=2, sort_keys=True))
return 0
if args.reset:
path = cfg.project_config_path() if args.scope == "project" else cfg.GLOBAL_CONFIG_PATH
if path.exists():
path.unlink(); print(f"removed {path}")
else:
print(f"no config at {path}")
return 0
config = cfg.load_config()
if args.set:
for item in args.set:
if "=" not in item:
print(f"error: --set expects key=value, got '{item}'", file=sys.stderr)
return 2
k, v = item.split("=", 1)
if k in NUMERIC_KEYS:
try:
v = float(v)
except ValueError:
pass
config[k] = v
elif not args.defaults:
if sys.stdin.isatty():
config = run_interactive(config)
else:
print("non-interactive shell: use --defaults or --set key=value. Showing questions:\n")
_print_questions()
return 0
config["setup_completed_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat()
path = cfg.write_config(config, scope=args.scope)
print(f"saved {cfg.SKILL} customization -> {path}")
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/program_budget_planner.py
#!/usr/bin/env python3
"""program_budget_planner.py - Build a multi-period R&D program budget with F&A split.
Stdlib-only. Deterministic. NO LLM calls. Every output surfaces an explicit assumptions
block: budget math without disclosed assumptions is theatre.
Takes work-package line items, applies the F&A (indirect) rate to the F&A-eligible base
(MTDC-style: excludes capital equipment and the portion of subawards over $25k), computes
fully-loaded cost, and rolls up per period.
Usage:
python3 program_budget_planner.py --sample
python3 program_budget_planner.py --input program.json --fa-rate 0.55 --periods 4
python3 program_budget_planner.py --input program.json --profile biotech --output json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import config_loader as _cfg
except ImportError: # pragma: no cover
_cfg = None
# Profile default F&A (indirect) rate and escalation assumption when not supplied in input.
PROFILES = {
"pharma-rd": {"default_fa_rate": 0.50, "annual_escalation": 0.03},
"biotech": {"default_fa_rate": 0.55, "annual_escalation": 0.04},
"medtech": {"default_fa_rate": 0.45, "annual_escalation": 0.03},
"deep-tech": {"default_fa_rate": 0.40, "annual_escalation": 0.03},
"software-rd": {"default_fa_rate": 0.30, "annual_escalation": 0.04},
"university-lab": {"default_fa_rate": 0.585, "annual_escalation": 0.025},
}
# Categories excluded from the F&A (MTDC) base.
FA_EXEMPT_CATEGORIES = {"capital_equipment", "subaward_over_25k", "tuition", "patient_care"}
SAMPLE = {
"program": "Next-Gen Assay Platform",
"periods": 4,
"work_packages": [
{"name": "Personnel (FTEs)", "category": "personnel", "amounts": [320000, 330000, 340000, 350000]},
{"name": "Consumables", "category": "supplies", "amounts": [60000, 65000, 70000, 70000]},
{"name": "Sequencer", "category": "capital_equipment", "amounts": [180000, 0, 0, 0]},
{"name": "CRO subaward", "category": "subaward_over_25k", "amounts": [100000, 100000, 0, 0]},
{"name": "Travel", "category": "travel", "amounts": [12000, 12000, 12000, 12000]},
],
}
def _period_sum(amounts: list, n: int, idx: int) -> float:
return float(amounts[idx]) if idx < len(amounts) else 0.0
def plan_budget(data: dict, fa_rate: float, periods: int) -> dict:
wps = data.get("work_packages", [])
direct_by_period = [0.0] * periods
fa_base_by_period = [0.0] * periods
line_items = []
for wp in wps:
cat = wp.get("category", "other")
amounts = wp.get("amounts", [])
fa_eligible = cat not in FA_EXEMPT_CATEGORIES
wp_total = 0.0
for i in range(periods):
amt = _period_sum(amounts, periods, i)
direct_by_period[i] += amt
if fa_eligible:
fa_base_by_period[i] += amt
wp_total += amt
line_items.append({
"name": wp.get("name", "UNNAMED"),
"category": cat,
"fa_eligible": fa_eligible,
"total_direct": round(wp_total, 2),
})
fa_by_period = [round(b * fa_rate, 2) for b in fa_base_by_period]
loaded_by_period = [round(direct_by_period[i] + fa_by_period[i], 2) for i in range(periods)]
return {
"program": data.get("program", "UNSPECIFIED"),
"periods": periods,
"fa_rate_applied": fa_rate,
"line_items": line_items,
"direct_by_period": [round(x, 2) for x in direct_by_period],
"fa_base_by_period": [round(x, 2) for x in fa_base_by_period],
"fa_by_period": fa_by_period,
"fully_loaded_by_period": loaded_by_period,
"total_direct": round(sum(direct_by_period), 2),
"total_fa": round(sum(fa_by_period), 2),
"total_fully_loaded": round(sum(loaded_by_period), 2),
"assumptions": [
f"F&A (indirect) rate applied: {fa_rate:.1%}. Confirm this is your negotiated NICRA, not an assumption.",
f"F&A base excludes: {', '.join(sorted(FA_EXEMPT_CATEGORIES))} (MTDC-style base).",
"Amounts are taken as-entered per period; no escalation applied unless baked into inputs.",
"This is a planning estimate; a finance owner/controller validates the rate basis and booking.",
],
}
def _render_human(r: dict) -> str:
lines = [f"R&D Program Budget: {r['program']} ({r['periods']} periods)",
f"F&A rate applied: {r['fa_rate_applied']:.1%}", ""]
lines.append("Line items:")
for li in r["line_items"]:
tag = "F&A-eligible" if li["fa_eligible"] else "F&A-EXEMPT"
lines.append(f" {li['name']:24s} {li['category']:20s} {tag:12s} ${li['total_direct']:,.0f}")
lines.append("")
hdr = " " + "".join(f"P{i+1:>14}" for i in range(r["periods"]))
lines.append("Per-period rollup:" )
lines.append(hdr)
lines.append(" direct " + "".join(f"{v:>15,.0f}" for v in r["direct_by_period"]))
lines.append(" F&A " + "".join(f"{v:>15,.0f}" for v in r["fa_by_period"]))
lines.append(" loaded " + "".join(f"{v:>15,.0f}" for v in r["fully_loaded_by_period"]))
lines.append("")
lines.append(f"Total direct: ${r['total_direct']:,.0f}")
lines.append(f"Total F&A: ${r['total_fa']:,.0f}")
lines.append(f"Total fully-loaded: ${r['total_fully_loaded']:,.0f}")
lines.append("")
lines.append("Assumptions (state these alongside the number):")
for a in r["assumptions"]:
lines.append(f" - {a}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Build a multi-period R&D program budget with F&A split.")
p.add_argument("--input", help="Path to JSON program with work_packages[]")
p.add_argument("--profile", default=None, choices=list(PROFILES),
help="overrides onboarding default_profile")
p.add_argument("--fa-rate", type=float, default=None, help="Override F&A rate (fraction, e.g. 0.55)")
p.add_argument("--periods", type=int, default=None, help="Number of periods")
p.add_argument("--output", choices=["human", "json"], default="human")
p.add_argument("--sample", action="store_true", help="use the embedded sample")
args = p.parse_args(argv)
conf = _cfg.load_config() if _cfg else {}
profile = args.profile or conf.get("default_profile", "biotech")
data = SAMPLE if (args.sample or not args.input) else json.load(open(args.input))
periods = args.periods or int(data.get("periods", 4))
# F&A precedence: CLI flag > onboarding default_fa_rate (if set) > profile default
if args.fa_rate is not None:
fa_rate = args.fa_rate
elif conf.get("default_fa_rate") is not None:
fa_rate = float(conf["default_fa_rate"])
else:
fa_rate = PROFILES[profile]["default_fa_rate"]
result = plan_budget(data, fa_rate, periods)
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(_render_human(result))
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: research-finance
description: Use when managing the money for an internal R&D program or portfolio — building a multi-period program budget with the F&A (indirect) split, tracking burn rate and runway against value-inflection milestones, or routing R&D cost items to a capitalize-vs-expense determination. Every budget output surfaces its assumptions block; capitalize-vs-expense is decision-support only and routes to a named finance owner — it never books an entry or decides accounting treatment. Distinct from finance/financial-analysis (corporate DCF, close, valuation) and research/grants (funding discovery — this manages money already won).
version: 2.9.0
author: claude-code-skills
license: MIT
tags: [research-ops, research-finance, rd-budget, burn-rate, runway, fa-rate, capitalize-vs-expense, portfolio]
compatible_tools: [claude-code, codex-cli, cursor, antigravity, opencode, gemini-cli]
---
# research-finance
Financial management of internal R&D programs and portfolios: program budgeting with F&A, burn/runway tracking, and capitalize-vs-expense routing. Every number ships with its **assumptions block**, and accounting-treatment calls **route to a named finance owner** — this skill never books an entry.
## Purpose
R&D finance partners, program controllers, and operations leads manage money that has already been allocated or raised — not the corporate close, not the next funding round, not finding a grant. This skill structures three recurring decisions:
Three deterministic tools:
1. `program_budget_planner.py` — Builds a multi-period budget from work-package line items, applies the F&A (indirect) rate to an MTDC-style eligible base, and rolls up direct / F&A / fully-loaded cost per period with an explicit assumptions block.
2. `burn_runway_tracker.py` — Computes average + trailing burn, runway in periods/months, and whether each value-inflection milestone is reachable before cash runs out. Flags accelerating burn and below-threshold runway.
3. `capex_vs_opex_router.py` — Scores each R&D cost item against the IAS 38 development-phase criteria (or flags US GAAP ASC 730 expense-as-incurred) and routes it to **CAPITALIZE-CANDIDATE / EXPENSE / FINANCE-OWNER-REVIEW** with a named owner. Never auto-decides.
## When to use
Invoke this skill when:
- You are building or revising an R&D program budget and need the F&A split made explicit.
- A program's runway is in question and you need a milestone-vs-cash read.
- Finance asks whether a development cost can be capitalized and you need a defensible first routing.
- You are preparing a portfolio review and need per-program burn consistency.
**Do NOT use this skill to**: run corporate DCF / valuation / close (use `finance/financial-analysis`), discover or position grants (use `research/grants`), or make the final accounting determination (that is the controller's + auditor's call — this tool only routes).
## Workflow
1. **Lay out the program** — Fill `assets/rd_program_budget_template.md` with work-package lines, categories, and per-period amounts.
2. **Build the budget** — Run `program_budget_planner.py --input program.json --profile {pharma-rd|biotech|medtech|deep-tech|software-rd|university-lab} --fa-rate <negotiated rate>`. Read direct / F&A / fully-loaded rollups + assumptions.
3. **Track burn & runway** — Run `burn_runway_tracker.py --input ledger.json --threshold-months 6`. Read runway + milestone verdicts + flags.
4. **Route accounting treatment** — Run `capex_vs_opex_router.py --input costs.json --standard {ifrs|usgaap}`. Read the per-item routing; send CAPITALIZE-CANDIDATE and FINANCE-OWNER-REVIEW items to the named owner.
5. **Assemble the review** — Combine into a program-finance packet. Every number carries its assumptions; treatment calls carry a named owner.
## Scripts
| Script | Purpose | Profiles |
|---|---|---|
| `scripts/program_budget_planner.py` | Multi-period budget + F&A split + assumptions | pharma-rd, biotech, medtech, deep-tech, software-rd, university-lab |
| `scripts/burn_runway_tracker.py` | Burn, runway, milestone-vs-cash alignment | n/a (ledger-driven) |
| `scripts/capex_vs_opex_router.py` | IAS 38 / ASC 730 routing to named finance owner | pharma-rd, biotech, medtech, deep-tech, software-rd, university-lab |
All three: stdlib-only, `--help`, `--sample`, `--output {human,json}`.
## Onboarding & customization
Run the onboarding questionnaire **once before you start** — it captures your defaults so every tool in this skill is pre-configured. Customization is the point: the answers actually change tool behavior.
```bash
python3 scripts/onboard.py # interactive (also: --defaults, --set key=value, --reset)
python3 scripts/onboard.py --show # see the questions + current effective config
```
Answers are saved to `~/.config/research-ops/research-finance.json` (global) or `./.research-ops/research-finance.json` (`--scope project`) and are read automatically by `config_loader.py`. They set the default R&D-area **profile**, the default **F&A rate**, the **runway alert threshold**, the **accounting standard**, and the named **finance owner** printed on capitalize-vs-expense routing. CLI flags always override saved config; `RESEARCH_OPS_NO_CONFIG=1` ignores it.
**The five questions:** R&D area · F&A rate · runway threshold · accounting standard · finance owner.
## Optimize with autoresearch (opt-in)
This skill ships an **isolated, opt-in** bridge to `engineering/autoresearch-agent`. Only when you ask to "optimize" / "extend runway" / "run a loop" does an autoresearch experiment iteratively improve a program plan against this skill's runway metric. `scripts/ar_evaluator.py` is the ground-truth evaluator; it prints `runway_months: <float>` (higher is better).
```bash
/ar:setup --domain custom --name extend-runway \
--target ledger.json \
--eval "python3 ar_evaluator.py --target ledger.json" \
--metric runway_months --direction higher
/ar:loop custom/extend-runway
```
Isolated: no hard dependency — autoresearch runs only on demand, and the loop edits `ledger.json`, never the evaluator.
## References
- `references/rd_program_finance_canon.md` — IAS 38 (research vs development); ASC 730 + ASC 985-20; Uniform Guidance 2 CFR 200 (F&A); FASB/IFRS capitalization criteria; NICRA basics.
- `references/burn_and_portfolio.md` — Cooper stage-gate; rNPV / real-options for R&D; risk-adjusted portfolio ROI; burn-rate / runway frameworks; milestone-based budgeting.
- `references/indirect_rate_modeling.md` — F&A pool composition (facilities + administration); MTDC base; de minimis 10%; fringe/overhead loading; CAS primer.
## Assumptions
- The F&A rate is the most error-prone input. The planner applies whatever rate you pass; it warns you to confirm it is a negotiated NICRA, not a guess.
- Burn/runway uses the trailing (recent-weighted) burn as the forward run-rate and assumes flat forward spend unless your ledger encodes a ramp.
- The capex router asserts criteria from your input; asserting "technical feasibility" does not make it true — the named finance owner and auditor validate it.
- Profiles annotate context (e.g., "most drug R&D is expensed") but do not change the accounting test.
## Anti-patterns
- **Stating a budget number without its assumptions.** F&A rate, escalation, and base must travel with the number.
- **Auto-deciding capitalize-vs-expense.** This tool routes; the controller (and auditor where required) decides.
- **Using lifetime-average burn for runway.** Recent burn is the honest forward run-rate; averages hide a slowdown or a ramp.
- **Applying F&A to the full base.** Capital equipment, large subaward portions, and certain categories are MTDC-exempt.
- **Confusing this with corporate finance.** Valuation, close, and fundraising live in `finance/`.
## Distinct from
| Sibling / neighbor | Scope | Difference |
|---|---|---|
| `finance/financial-analysis` | Corporate DCF, ratios, close, rolling forecast, SaaS metrics | That is **company-level**; this is **R&D-program-level** |
| `research/grants` | NIH funding discovery + positioning | That **finds funding**; this **manages money already won** |
| `clinical-research` (sibling) | Study design + feasibility + budget gate-check | That **scopes** the study; this **funds + tracks** the program |
| `ra-qm-team` | Regulatory/QM submission | Unrelated — no financial scope |
## Quick examples
```bash
python3 scripts/program_budget_planner.py --sample
python3 scripts/program_budget_planner.py --input program.json --profile university-lab --fa-rate 0.585
python3 scripts/burn_runway_tracker.py --sample --output json
python3 scripts/capex_vs_opex_router.py --sample --standard ifrs
```
The sample budget excludes the sequencer (capital equipment) and CRO subaward from the F&A base; the capex router routes exploratory screening to EXPENSE, a fully-criteria'd pilot line to CAPITALIZE-CANDIDATE, and a partial-criteria software build to FINANCE-OWNER-REVIEW.
## Forcing-question library (Matt Pocock grill discipline)
Walked one at a time by `/cs:grill-research-ops` or the orchestrator. Recommended answer + canon citation per question. Never bundled.
1. **"Is this spend in the research phase or the development phase — and can you evidence technical feasibility?"**
Recommended: research = expense; development = capitalize-candidate only with feasibility evidence, routed to a named finance owner.
Canon: IAS 38.54-57; ASC 730.
2. **"What F&A / indirect rate are you applying, and is it your negotiated NICRA, a de minimis 10%, or an assumption?"**
Recommended: use the negotiated rate; if assumed, flag it explicitly.
Canon: 2 CFR 200 (Uniform Guidance); NICRA basics.
3. **"What's runway in months at current burn, and does it clear the next value-inflection milestone?"**
Recommended: runway must cover the milestone plus a buffer; surface the gap.
Canon: Cooper stage-gate; SaaS/startup efficiency frameworks (a16z, Bessemer).
4. **"Is portfolio ROI risk-adjusted (rNPV / probability-of-success weighted) or raw NPV?"**
Recommended: risk-adjusted; raw NPV overstates R&D value.
Canon: rNPV drug-development valuation; real-options literature.
5. **"Who is the named finance / controller owner who signs the capitalize-vs-expense treatment?"**
Recommended: name them — this tool recommends, it never books the entry.
Canon: ASC 730 / IAS 38 governance; auditor sign-off requirements.
Walk depth-first. Lock 1-2 before opening 3-5. After all are answered, invoke `program_budget_planner.py` → `burn_runway_tracker.py` → `capex_vs_opex_router.py`.