Initial commit: Lean Experiment MCP server v1.0.0
This commit is contained in:
commit
cd7662b1c7
7 changed files with 1746 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
89
README.md
Normal file
89
README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Lean Experiment MCP Server
|
||||||
|
|
||||||
|
A Model Context Protocol (MCP) server for running Lean Startup experiments. Define hypotheses, track observations, and make data-driven decisions — all from within your agent workflow.
|
||||||
|
|
||||||
|
## Why This Exists
|
||||||
|
|
||||||
|
Agents need to validate ideas before building. This MCP server gives agents the same Lean Startup tooling that human startups use: hypothesis definition, metric tracking, and explicit persevere/pivot/kill decisions.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### `define_experiment`
|
||||||
|
Create a new experiment with hypothesis, success criteria, and kill criteria.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MCP Server Adoption Test",
|
||||||
|
"hypothesis": "If I publish a free MCP server, 50+ agents will install it in 30 days because the registry has 180k users",
|
||||||
|
"success_criteria": "50+ stars/downloads in 30 days, 5+ issues opened",
|
||||||
|
"kill_criteria": "Zero engagement after 14 days",
|
||||||
|
"duration_days": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `record_observation`
|
||||||
|
Log a data point for an active experiment.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_id": "exp_...",
|
||||||
|
"metric": "github_stars",
|
||||||
|
"value": 12,
|
||||||
|
"notes": "Posted on HN, got 3 upvotes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_experiment_status`
|
||||||
|
Get full status with metrics summary, days remaining, and observations.
|
||||||
|
|
||||||
|
### `list_experiments`
|
||||||
|
List all experiments with status, day count, and observation count.
|
||||||
|
|
||||||
|
### `make_decision`
|
||||||
|
Make a decision: **persevere**, **pivot**, or **kill**.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_id": "exp_...",
|
||||||
|
"decision": "kill",
|
||||||
|
"reason": "Zero installs after 14 days. Hypothesis invalidated."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `update_experiment`
|
||||||
|
Update notes or extend duration.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Via npx (no install)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lean-experiments": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["lean-experiment-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via npm
|
||||||
|
```bash
|
||||||
|
npm install -g lean-experiment-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
All experiments stored in `~/.lean-experiments/experiments.db` (SQLite).
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
1. **Define:** `define_experiment` with hypothesis and success/kill criteria
|
||||||
|
2. **Ship:** Build the thing, publish it
|
||||||
|
3. **Record:** Every few days, log metrics with `record_observation`
|
||||||
|
4. **Decide:** At the end, use `make_decision` to persevere, pivot, or kill
|
||||||
|
5. **Learn:** Review all experiments with `list_experiments`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
1177
package-lock.json
generated
Normal file
1177
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "lean-experiment-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP server for running Lean Startup experiments — define hypotheses, track observations, make data-driven decisions",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"lean-experiment-mcp": "dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "node --test dist/**/*.test.js"
|
||||||
|
},
|
||||||
|
"keywords": ["mcp", "lean-startup", "experiment", "hypothesis", "agent-tools"],
|
||||||
|
"author": "Marcus (PageZero)",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/index.ts
Normal file
225
src/index.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { ExperimentStore } from "./store.js";
|
||||||
|
|
||||||
|
const store = new ExperimentStore();
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{ name: "lean-experiment-mcp", version: "1.0.0" },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "define_experiment",
|
||||||
|
description: "Define a new Lean Startup experiment with hypothesis, success criteria, and kill criteria",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Short name for the experiment" },
|
||||||
|
hypothesis: { type: "string", description: "If we do X, then Y will happen because Z" },
|
||||||
|
success_criteria: { type: "string", description: "What success looks like (e.g., '50+ signups in 30 days')" },
|
||||||
|
kill_criteria: { type: "string", description: "When to stop (e.g., 'zero engagement after 14 days')" },
|
||||||
|
duration_days: { type: "number", description: "How long to run the experiment", default: 30 },
|
||||||
|
notes: { type: "string", description: "Optional additional context" },
|
||||||
|
},
|
||||||
|
required: ["name", "hypothesis", "success_criteria", "kill_criteria"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record_observation",
|
||||||
|
description: "Record a data point or observation for an active experiment",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
experiment_id: { type: "string", description: "ID of the experiment" },
|
||||||
|
metric: { type: "string", description: "Metric name (e.g., 'signups', 'revenue', 'page_views')" },
|
||||||
|
value: { type: "number", description: "Numeric value" },
|
||||||
|
notes: { type: "string", description: "Context for this observation" },
|
||||||
|
},
|
||||||
|
required: ["experiment_id", "metric", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_experiment_status",
|
||||||
|
description: "Get current status of an experiment including days remaining, observations, and recommendation",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
experiment_id: { type: "string", description: "ID of the experiment" },
|
||||||
|
},
|
||||||
|
required: ["experiment_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_experiments",
|
||||||
|
description: "List all experiments with their current status",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["active", "completed", "killed", "all"], default: "all" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "make_decision",
|
||||||
|
description: "Make a decision on an experiment: persevere, pivot, or kill",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
experiment_id: { type: "string", description: "ID of the experiment" },
|
||||||
|
decision: { type: "string", enum: ["persevere", "pivot", "kill"], description: "Decision to make" },
|
||||||
|
reason: { type: "string", description: "Why this decision was made" },
|
||||||
|
},
|
||||||
|
required: ["experiment_id", "decision", "reason"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_experiment",
|
||||||
|
description: "Update experiment notes or extend duration",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
experiment_id: { type: "string", description: "ID of the experiment" },
|
||||||
|
notes: { type: "string", description: "Updated notes" },
|
||||||
|
duration_days: { type: "number", description: "New total duration" },
|
||||||
|
},
|
||||||
|
required: ["experiment_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "define_experiment": {
|
||||||
|
const exp = store.createExperiment(
|
||||||
|
args.name as string,
|
||||||
|
args.hypothesis as string,
|
||||||
|
args.success_criteria as string,
|
||||||
|
args.kill_criteria as string,
|
||||||
|
(args.duration_days as number) || 30,
|
||||||
|
(args.notes as string) || ""
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Experiment defined!\n\nID: ${exp.id}\nName: ${exp.name}\nHypothesis: ${exp.hypothesis}\nSuccess: ${exp.success_criteria}\nKill: ${exp.kill_criteria}\nDuration: ${exp.duration_days} days\nStarted: ${exp.started_at}\n\nUse record_observation with experiment_id="${exp.id}" to track data.`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "record_observation": {
|
||||||
|
const obs = store.addObservation(
|
||||||
|
args.experiment_id as string,
|
||||||
|
args.metric as string,
|
||||||
|
args.value as number,
|
||||||
|
(args.notes as string) || ""
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Observation recorded!\n\nExperiment: ${args.experiment_id}\nMetric: ${obs.metric}\nValue: ${obs.value}\nTime: ${obs.timestamp}\n${obs.notes ? `Notes: ${obs.notes}` : ""}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get_experiment_status": {
|
||||||
|
const status = store.getStatus(args.experiment_id as string);
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: formatStatus(status),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list_experiments": {
|
||||||
|
const filter = (args.status as string) || "all";
|
||||||
|
const exps = store.listExperiments(filter as any);
|
||||||
|
if (exps.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No experiments found." }] };
|
||||||
|
}
|
||||||
|
const lines = exps.map(e =>
|
||||||
|
`[${e.status.toUpperCase()}] ${e.name} (${e.id}) — Day ${e.days_elapsed}/${e.duration_days} — ${e.observation_count} observations`
|
||||||
|
);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "make_decision": {
|
||||||
|
const result = store.makeDecision(
|
||||||
|
args.experiment_id as string,
|
||||||
|
args.decision as "persevere" | "pivot" | "kill",
|
||||||
|
args.reason as string
|
||||||
|
);
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Decision recorded!\n\nExperiment: ${result.name}\nDecision: ${result.status.toUpperCase()}\nReason: ${result.decision_reason}\nEnded: ${result.ended_at}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "update_experiment": {
|
||||||
|
const updated = store.updateExperiment(
|
||||||
|
args.experiment_id as string,
|
||||||
|
args.notes as string | undefined,
|
||||||
|
args.duration_days as number | undefined
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Experiment not found: ${args.experiment_id}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Experiment updated!\n\nID: ${updated.id}\nName: ${updated.name}\nDuration: ${updated.duration_days} days\nNotes: ${updated.notes || "(none)"}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatStatus(s: any): string {
|
||||||
|
const metrics = Object.entries(s.metrics_by_name)
|
||||||
|
.map(([k, v]: [string, any]) => ` ${k}: ${v.total} total, ${v.latest} latest, ${v.count} data points`)
|
||||||
|
.join("\n") || " (no observations yet)";
|
||||||
|
|
||||||
|
return `# ${s.name}\nStatus: ${s.status.toUpperCase()}\nDay: ${s.days_elapsed}/${s.duration_days}\nStarted: ${s.started_at}\n${s.ended_at ? `Ended: ${s.ended_at}\n` : ""}\nHypothesis: ${s.hypothesis}\nSuccess: ${s.success_criteria}\nKill: ${s.kill_criteria}\n\nMetrics:\n${metrics}\n\nObservations: ${s.observation_count}\n${s.decision_reason ? `Decision: ${s.decision_reason}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error("Lean Experiment MCP server running on stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
209
src/store.ts
Normal file
209
src/store.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const DB_DIR = join(homedir(), ".lean-experiments");
|
||||||
|
const DB_PATH = join(DB_DIR, "experiments.db");
|
||||||
|
|
||||||
|
export interface Experiment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hypothesis: string;
|
||||||
|
success_criteria: string;
|
||||||
|
kill_criteria: string;
|
||||||
|
duration_days: number;
|
||||||
|
notes: string;
|
||||||
|
status: "active" | "completed" | "killed";
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
decision_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Observation {
|
||||||
|
id: string;
|
||||||
|
experiment_id: string;
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
notes: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExperimentStore {
|
||||||
|
private db: DatabaseSync;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mkdirSync(DB_DIR, { recursive: true });
|
||||||
|
this.db = new DatabaseSync(DB_PATH);
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS experiments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
hypothesis TEXT NOT NULL,
|
||||||
|
success_criteria TEXT NOT NULL,
|
||||||
|
kill_criteria TEXT NOT NULL,
|
||||||
|
duration_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
decision_reason TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS observations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
experiment_id TEXT NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (experiment_id) REFERENCES experiments(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createExperiment(
|
||||||
|
name: string,
|
||||||
|
hypothesis: string,
|
||||||
|
successCriteria: string,
|
||||||
|
killCriteria: string,
|
||||||
|
durationDays = 30,
|
||||||
|
notes = ""
|
||||||
|
): Experiment {
|
||||||
|
const id = `exp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO experiments (id, name, hypothesis, success_criteria, kill_criteria, duration_days, notes, status, started_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(id, name, hypothesis, successCriteria, killCriteria, durationDays, notes, "active", startedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
hypothesis,
|
||||||
|
success_criteria: successCriteria,
|
||||||
|
kill_criteria: killCriteria,
|
||||||
|
duration_days: durationDays,
|
||||||
|
notes,
|
||||||
|
status: "active",
|
||||||
|
started_at: startedAt,
|
||||||
|
ended_at: null,
|
||||||
|
decision_reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addObservation(experimentId: string, metric: string, value: number, notes = ""): Observation {
|
||||||
|
const id = `obs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO observations (id, experiment_id, metric, value, notes, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(id, experimentId, metric, value, notes, timestamp);
|
||||||
|
|
||||||
|
return { id, experiment_id: experimentId, metric, value, notes, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(experimentId: string): any {
|
||||||
|
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
|
||||||
|
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
|
||||||
|
if (!exp) return null;
|
||||||
|
|
||||||
|
const obsStmt = this.db.prepare(`SELECT * FROM observations WHERE experiment_id = ? ORDER BY timestamp`);
|
||||||
|
const observations = obsStmt.all(experimentId) as unknown as Observation[];
|
||||||
|
|
||||||
|
const daysElapsed = Math.floor(
|
||||||
|
(Date.now() - new Date(exp.started_at).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
const metricsByName: Record<string, { total: number; latest: number; count: number }> = {};
|
||||||
|
for (const obs of observations) {
|
||||||
|
if (!metricsByName[obs.metric]) {
|
||||||
|
metricsByName[obs.metric] = { total: 0, latest: obs.value, count: 0 };
|
||||||
|
}
|
||||||
|
metricsByName[obs.metric].total += obs.value;
|
||||||
|
metricsByName[obs.metric].latest = obs.value;
|
||||||
|
metricsByName[obs.metric].count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...exp,
|
||||||
|
days_elapsed: daysElapsed,
|
||||||
|
observation_count: observations.length,
|
||||||
|
metrics_by_name: metricsByName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listExperiments(status?: "active" | "completed" | "killed" | "all"): Array<Experiment & { days_elapsed: number; observation_count: number }> {
|
||||||
|
let query = `SELECT * FROM experiments`;
|
||||||
|
if (status && status !== "all") {
|
||||||
|
query += ` WHERE status = '${status}'`;
|
||||||
|
}
|
||||||
|
query += ` ORDER BY started_at DESC`;
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(query);
|
||||||
|
const exps = stmt.all() as unknown as Experiment[];
|
||||||
|
|
||||||
|
return exps.map((exp) => {
|
||||||
|
const obsStmt = this.db.prepare(`SELECT COUNT(*) as count FROM observations WHERE experiment_id = ?`);
|
||||||
|
const { count } = obsStmt.get(exp.id) as { count: number };
|
||||||
|
const daysElapsed = Math.floor(
|
||||||
|
(Date.now() - new Date(exp.started_at).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
return { ...exp, days_elapsed: daysElapsed, observation_count: count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
makeDecision(experimentId: string, decision: "persevere" | "pivot" | "kill", reason: string): Experiment | null {
|
||||||
|
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
|
||||||
|
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
|
||||||
|
if (!exp) return null;
|
||||||
|
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
const newStatus = decision === "persevere" ? "active" : decision === "pivot" ? "completed" : "killed";
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE experiments SET status = ?, decision_reason = ?, ended_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(newStatus, reason, endedAt, experimentId);
|
||||||
|
|
||||||
|
return { ...exp, status: newStatus, decision_reason: reason, ended_at: endedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExperiment(experimentId: string, notes?: string, durationDays?: number): Experiment | null {
|
||||||
|
const expStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
|
||||||
|
const exp = expStmt.get(experimentId) as unknown as Experiment | undefined;
|
||||||
|
if (!exp) return null;
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (notes !== undefined) {
|
||||||
|
updates.push("notes = ?");
|
||||||
|
values.push(notes);
|
||||||
|
}
|
||||||
|
if (durationDays !== undefined) {
|
||||||
|
updates.push("duration_days = ?");
|
||||||
|
values.push(durationDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) return exp;
|
||||||
|
|
||||||
|
values.push(experimentId);
|
||||||
|
const stmt = this.db.prepare(`UPDATE experiments SET ${updates.join(", ")} WHERE id = ?`);
|
||||||
|
stmt.run(...values);
|
||||||
|
|
||||||
|
const updatedStmt = this.db.prepare(`SELECT * FROM experiments WHERE id = ?`);
|
||||||
|
return updatedStmt.get(experimentId) as unknown as Experiment;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue