Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions universal-refiner/src/core/blackboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export class AgenticBlackboard {
private static listeners: Array<() => void> = [];

private static getGlobalDir(): string {
return process.env.PROMPT_REFINER_GLOBAL_DIR || path.join(os.homedir(), ".refiner");
return process.env.PROMPT_REFINER_GLOBAL_DIR
|| (process.env.PROMPT_REFINER_PROJECT_DIR
? path.join(process.env.PROMPT_REFINER_PROJECT_DIR, ".global-refiner")
: path.join(os.homedir(), ".refiner"));
}

private static getGlobalLogPath(): string {
Expand All @@ -60,7 +63,10 @@ export class AgenticBlackboard {
}

private static getStoragePath(rootPath: string): string {
const projectRoot = this.findProjectRoot(rootPath);
const effectiveRoot = rootPath === "."
? process.env.PROMPT_REFINER_PROJECT_DIR || rootPath
: rootPath;
const projectRoot = this.findProjectRoot(effectiveRoot);
return path.join(projectRoot, this.DOT_REFINER, this.STORAGE_NAME);
}

Expand Down
11 changes: 11 additions & 0 deletions universal-refiner/src/core/ports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const DEFAULT_DASHBOARD_PORT = 3000;

export function resolveDashboardPort(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.PROMPT_REFINER_DASHBOARD_PORT || env.PORT;
if (!raw) return DEFAULT_DASHBOARD_PORT;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) {
throw new Error(`Invalid dashboard port: ${raw}`);
}
return parsed;
}
27 changes: 27 additions & 0 deletions universal-refiner/src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,20 @@ Output ONLY the JSON array. If no gaps, return [].`,
description: "Lists review-gated lesson and prompt-template candidates for the current repository.",
inputSchema: { type: "object", properties: {} }
},
{
name: "record_terminal_outcome",
description: "Records one evidence-backed terminal goal outcome and an optional review-gated lesson candidate.",
inputSchema: {
type: "object",
properties: {
goal_id: { type: "string" },
status: { type: "string", enum: ["completed", "failed", "cancelled", "blocked", "budget_exhausted"] },
evidence: { type: "array", items: { type: "string" }, minItems: 1 },
summary: { type: "string" }
},
required: ["goal_id", "status", "evidence", "summary"]
}
},
{
name: "review_lesson",
description: "Approves or rejects a proposed lesson before it can influence future prompts.",
Expand Down Expand Up @@ -552,6 +566,19 @@ Output ONLY the JSON array. If no gaps, return [].`,
const candidates = this.eventStore.getLearningCandidates(this.repository.id);
return { content: [{ type: "text", text: JSON.stringify(candidates) }] };
}
case "record_terminal_outcome": {
const outcome = z.object({
goal_id: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled", "blocked", "budget_exhausted"]),
evidence: z.array(z.string().min(1)).min(1),
summary: z.string().min(1),
}).parse(request.params.arguments);
Comment on lines +570 to +575

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept lesson candidates in the MCP outcome tool

The new tool description says it can record an optional review-gated lesson candidate, and recordTerminalOutcome only creates that pending lesson when outcome.candidate is present, but this dispatcher schema parses only goal/status/evidence/summary. As a result, clients using the advertised MCP surface have no way to submit the candidate, so list_learning_candidates remains empty after terminal-outcome recording; include and validate the candidate fields here and in the advertised input schema if this tool is meant to seed review-gated lessons.

Useful? React with 👍 / 👎.

const recorded = this.eventStore.recordTerminalOutcome({
...outcome,
repo_id: this.repository.id,
});
return { content: [{ type: "text", text: JSON.stringify({ recorded, goal_id: outcome.goal_id }) }] };
}
case "review_lesson": {
const { id, approved } = z.object({ id: z.string(), approved: z.boolean() }).parse(request.params.arguments);
const changed = this.eventStore.reviewLesson(this.repository.id, id, approved);
Expand Down
46 changes: 46 additions & 0 deletions universal-refiner/src/history/event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,52 @@ export class EventStore {
);
}

recordTerminalOutcome(outcome: {
goal_id: string;
repo_id?: string;
status: "completed" | "failed" | "cancelled" | "blocked" | "budget_exhausted";
evidence: string[];
summary: string;
candidate?: {
id: string;
lesson_type: string;
title: string;
summary: string;
confidence: string;
};
}): boolean {
if (!outcome.goal_id.trim() || !outcome.summary.trim() || outcome.evidence.length === 0) {
throw new Error("Terminal outcomes require goal id, summary, and evidence.");
}
const now = new Date().toISOString();
const transaction = this.db.transaction(() => {
const inserted = this.db.prepare(`
INSERT OR IGNORE INTO terminal_outcomes (goal_id, repo_id, status, evidence_json, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(outcome.goal_id, outcome.repo_id || null, outcome.status, JSON.stringify(outcome.evidence), outcome.summary, now);
if (inserted.changes === 0) return false;
if (outcome.candidate) {
this.recordLesson({
id: outcome.candidate.id,
repo_id: outcome.repo_id,
lesson_type: outcome.candidate.lesson_type,
title: outcome.candidate.title,
summary: outcome.candidate.summary,
evidence_json: JSON.stringify(outcome.evidence),
confidence: outcome.candidate.confidence,
source: "terminal_outcome",
approved: 0,
});
}
return true;
});
return transaction();
}

getTerminalOutcome(goalId: string): any | undefined {
return this.db.prepare(`SELECT * FROM terminal_outcomes WHERE goal_id = ?`).get(goalId);
}

linkCommitToExecution(executionId: string, commitId: string) {
const stmt = this.db.prepare(`
INSERT OR IGNORE INTO execution_commits (execution_id, commit_id)
Expand Down
9 changes: 9 additions & 0 deletions universal-refiner/src/history/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ CREATE TABLE IF NOT EXISTS executions (
artifacts_json TEXT NOT NULL DEFAULT '{}'
);

CREATE TABLE IF NOT EXISTS terminal_outcomes (
goal_id TEXT PRIMARY KEY,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope terminal outcomes by repository

When the shared event store is used for multiple repositories, this primary key makes goal_id globally unique even though the table also stores repo_id and the MCP server writes the current repository ID. If two repos report the same goal ID (for example a common run/goal name), the second insert is ignored by INSERT OR IGNORE, so that repo silently loses its terminal outcome and any associated learning candidate. Make idempotency apply per repo, e.g. with a composite key including repo_id.

Useful? React with 👍 / 👎.

repo_id TEXT,
status TEXT NOT NULL,
evidence_json TEXT NOT NULL,
summary TEXT NOT NULL,
created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS tests (
id TEXT PRIMARY KEY,
execution_id TEXT NOT NULL,
Expand Down
10 changes: 8 additions & 2 deletions universal-refiner/tests/acceptance/mcp-tools.acceptance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("MCP all-tool acceptance", () => {
const names = listResponse.tools.map(tool => tool.name);

expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(19);
expect(names).toHaveLength(20);
for (const tool of listResponse.tools) {
expect(tool.description.length).toBeGreaterThan(10);
expect(tool.inputSchema.type).toBe("object");
Expand Down Expand Up @@ -142,6 +142,12 @@ describe("MCP all-tool acceptance", () => {
discover_rules: {},
approve_rule: { id: "missing-rule" },
list_learning_candidates: {},
record_terminal_outcome: {
goal_id: "acceptance-goal",
status: "completed",
evidence: ["cas://evidence/acceptance"],
summary: "Acceptance outcome",
},
review_lesson: { id: "pending-lesson", approved: true },
review_template: { id: "pending-template", approved: true },
ingest_pattern: { id: "pattern", category: "quality", description: "Verify changes." },
Expand All @@ -163,5 +169,5 @@ describe("MCP all-tool acceptance", () => {
await expect(dispatch({ params: { name: tool.name, arguments: args[tool.name] } }))
.resolves.toHaveProperty("content");
}
});
}, 15_000);
});
25 changes: 23 additions & 2 deletions universal-refiner/tests/blackboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,32 @@ describe("AgenticBlackboard", () => {
});

it("uses the home-directory global store when no override is configured", () => {
const previousHome = process.env.HOME;
const previousProfile = process.env.USERPROFILE;
delete process.env.PROMPT_REFINER_GLOBAL_DIR;
delete process.env.PROMPT_REFINER_PROJECT_DIR;
process.env.HOME = tmpDir;
process.env.USERPROFILE = tmpDir;
try {
const data = AgenticBlackboard.getGlobalData();
expect(data).toHaveProperty("logs");
expect(data).toHaveProperty("projects");
expect(Array.isArray(AgenticBlackboard.getLogs("."))).toBe(true);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousProfile;
}
});

it("uses the isolated project fallback when only project storage is configured", () => {
delete process.env.PROMPT_REFINER_GLOBAL_DIR;
process.env.PROMPT_REFINER_PROJECT_DIR = tmpDir;

const data = AgenticBlackboard.getGlobalData();

expect(data).toHaveProperty("logs");
expect(data).toHaveProperty("projects");
expect(data).toEqual({ logs: [], projects: [] });
expect(fs.existsSync(path.join(tmpDir, ".global-refiner", "global_history.json"))).toBe(true);
});
});
45 changes: 45 additions & 0 deletions universal-refiner/tests/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,51 @@ describe("EventStore", () => {
expect(store.getRecentLessons("repo").map(lesson => lesson.id)).toEqual(["approved"]);
});

it("records one terminal outcome and gates its lesson candidate on approval", () => {
const store = EventStore.getInstance();
const outcome = {
goal_id: "goal-1",
repo_id: "repo",
status: "completed" as const,
evidence: ["cas://evidence/verification/1"],
summary: "All mandatory checks passed.",
candidate: {
id: "lesson-goal-1",
lesson_type: "quality",
title: "Candidate lesson",
summary: "Preserve deterministic verification.",
confidence: "high",
},
};

expect(store.recordTerminalOutcome(outcome)).toBe(true);
expect(store.recordTerminalOutcome(outcome)).toBe(false);
expect(store.getTerminalOutcome("goal-1").status).toBe("completed");
expect(store.getLearningCandidates("repo").lessons.map(lesson => lesson.id)).toEqual(["lesson-goal-1"]);
expect(store.getRecentLessons("repo")).toEqual([]);

expect(store.reviewLesson("repo", "lesson-goal-1", true)).toBe(true);
expect(store.getRecentLessons("repo").map(lesson => lesson.id)).toContain("lesson-goal-1");

expect(store.recordTerminalOutcome({
goal_id: "goal-without-repo",
status: "completed",
evidence: ["cas://evidence/global"],
summary: "Global terminal outcome",
})).toBe(true);
});

it("rejects terminal outcomes without required evidence", () => {
const store = EventStore.getInstance();

expect(() => store.recordTerminalOutcome({
goal_id: "goal-without-evidence",
status: "failed",
evidence: [],
summary: "Missing evidence",
})).toThrow("Terminal outcomes require goal id, summary, and evidence.");
});

it("should persist learning candidate approval and rejection", () => {
const store = EventStore.getInstance();
store.recordLesson({
Expand Down
17 changes: 17 additions & 0 deletions universal-refiner/tests/ports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { resolveDashboardPort } from "../src/core/ports.js";

describe("resolveDashboardPort", () => {
it("uses the dedicated port, generic fallback, and default in priority order", () => {
expect(resolveDashboardPort({ PROMPT_REFINER_DASHBOARD_PORT: "4100", PORT: "4200" })).toBe(4100);
expect(resolveDashboardPort({ PORT: "4200" })).toBe(4200);
expect(resolveDashboardPort({})).toBe(3000);
});

it.each(["0", "65536", "not-a-number", "1.5"])("rejects invalid port %s", (value) => {
expect(() => resolveDashboardPort({ PROMPT_REFINER_DASHBOARD_PORT: value })).toThrow(
`Invalid dashboard port: ${value}`,
);
});
});
10 changes: 10 additions & 0 deletions universal-refiner/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

const isolationRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prompt-refiner-tests-"));
const projectRoot = path.join(isolationRoot, "project");

fs.mkdirSync(path.join(projectRoot, ".refiner"), { recursive: true });
process.env.PROMPT_REFINER_PROJECT_DIR = projectRoot;
process.env.PROMPT_REFINER_GLOBAL_DIR = path.join(isolationRoot, "global");
2 changes: 1 addition & 1 deletion universal-refiner/tests/stress/event-store.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("EventStore stress and restart persistence", () => {

const db = (store as unknown as { db: { prepare: (sql: string) => { get: () => { count: number } } } }).db;
expect(db.prepare("SELECT COUNT(*) AS count FROM events WHERE event_type = 'stress'").get().count).toBe(500);
});
}, 20_000);

it("retains events after the store is closed and reopened", () => {
const holder = EventStore as unknown as { instance: EventStore | null };
Expand Down
2 changes: 2 additions & 0 deletions universal-refiner/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
exclude: ["node_modules/**", "dist/**", "tests/e2e/**"],
setupFiles: ["./tests/setup.ts"],
coverage: {
provider: "v8",
include: ["hooks/lib/**/*.ts", "src/**/*.ts"],
Expand Down