Sprint 2 engine: TCO, investment, ramp-aware cash flow, scenarios, planner overrides, shelfware, KB curation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:20:36 -04:00
parent d5c77543e8
commit 4496312845
19 changed files with 1223 additions and 126 deletions

View File

@@ -1,8 +1,9 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
import type { Assessment, AssessmentSummary, Branding, Catalog, KnowledgeBase, Snapshot, SnapshotMeta, Survey } from "./types";
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
import { evaluate } from "./finance";
import { forgetAssessment, learnFromAssessment } from "./kb";
import { isSurveyToken } from "./survey";
@@ -11,6 +12,8 @@ const CATALOG_PATH = path.join(DATA_DIR, "catalog.json");
const KB_PATH = path.join(DATA_DIR, "knowledge-base.json");
const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments");
const SURVEY_DIR = path.join(DATA_DIR, "surveys");
const SNAPSHOT_DIR = path.join(DATA_DIR, "snapshots");
const SETTINGS_PATH = path.join(DATA_DIR, "settings.json");
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await fs.readFile(file, "utf8")) as T;
@@ -73,6 +76,7 @@ export async function listAssessments(): Promise<AssessmentSummary[]> {
preparedBy: a.preparedBy,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
status: a.status ?? "open",
candidateCount: a.candidates.length,
annualSpend: r.addressableSpend,
annualSavings: r.totalAnnualSavings,
@@ -114,6 +118,7 @@ export async function deleteAssessment(id: string) {
await fs.rm(assessmentPath(id), { force: true });
for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true });
await fs.rm(path.join(SNAPSHOT_DIR, id), { recursive: true, force: true });
try {
const kb = await getKnowledgeBase();
@@ -236,3 +241,71 @@ export async function mutateSurvey(id: string, fn: (s: Survey) => void | string)
export async function deleteSurvey(id: string) {
await fs.rm(surveyPath(id), { force: true });
}
/* ------------------------------------------------------------ branding */
export const DEFAULT_BRANDING: Branding = { companyName: "", accent: "#005C8A", logo: "" };
export async function getBranding(): Promise<Branding> {
try {
return { ...DEFAULT_BRANDING, ...(await readJson<Partial<Branding>>(SETTINGS_PATH)) };
} catch {
return { ...DEFAULT_BRANDING };
}
}
export async function saveBranding(b: Branding): Promise<Branding> {
await writeJson(SETTINGS_PATH, b);
return b;
}
/* ------------------------------------------------------------ snapshots */
const snapshotDir = (assessmentId: string) => {
if (!isSafeId(assessmentId)) throw new Error(`Invalid assessment id: ${assessmentId}`);
return path.join(SNAPSHOT_DIR, assessmentId);
};
export async function listSnapshots(assessmentId: string): Promise<SnapshotMeta[]> {
const dir = snapshotDir(assessmentId);
await fs.mkdir(dir, { recursive: true });
const out: SnapshotMeta[] = [];
for (const f of await fs.readdir(dir)) {
if (!f.endsWith(".json")) continue;
try {
const { assessment: _a, ...meta } = await readJson<Snapshot>(path.join(dir, f));
out.push(meta);
} catch {
// Skip a damaged snapshot rather than hiding the rest.
}
}
return out.sort((x, y) => y.createdAt.localeCompare(x.createdAt));
}
export async function getSnapshot(assessmentId: string, id: string): Promise<Snapshot | null> {
if (!/^[a-z0-9-]{1,40}$/.test(id)) return null;
try {
return await readJson<Snapshot>(path.join(snapshotDir(assessmentId), `${id}.json`));
} catch {
return null;
}
}
/** Freezes the assessment as it stands, with its headline, under a label. */
export async function createSnapshot(a: Assessment, label: string, trigger: SnapshotMeta["trigger"]): Promise<SnapshotMeta> {
const catalog = await getCatalog();
const e = evaluate(a, catalog);
const createdAt = new Date().toISOString();
const snap: Snapshot = {
id: `${createdAt.replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`,
assessmentId: a.id,
label: label.trim().slice(0, 120) || "Snapshot",
trigger,
createdAt,
headline: { netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, tools: e.r.toolsRetired },
assessment: structuredClone(a),
};
await writeJson(path.join(snapshotDir(a.id), `${snap.id}.json`), snap);
const { assessment: _a, ...meta } = snap;
return meta;
}