diff --git a/universal-refiner/src/core/blackboard.ts b/universal-refiner/src/core/blackboard.ts index 15ea28f..d6ac035 100644 --- a/universal-refiner/src/core/blackboard.ts +++ b/universal-refiner/src/core/blackboard.ts @@ -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 { @@ -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); } diff --git a/universal-refiner/src/core/ports.ts b/universal-refiner/src/core/ports.ts new file mode 100644 index 0000000..dc6fd0e --- /dev/null +++ b/universal-refiner/src/core/ports.ts @@ -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; +} diff --git a/universal-refiner/src/core/server.ts b/universal-refiner/src/core/server.ts index 2f3fb8b..6949d04 100644 --- a/universal-refiner/src/core/server.ts +++ b/universal-refiner/src/core/server.ts @@ -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.", @@ -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); + 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); diff --git a/universal-refiner/src/history/event-store.ts b/universal-refiner/src/history/event-store.ts index 62bd4e8..602daab 100644 --- a/universal-refiner/src/history/event-store.ts +++ b/universal-refiner/src/history/event-store.ts @@ -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) diff --git a/universal-refiner/src/history/schema.ts b/universal-refiner/src/history/schema.ts index 77d5d30..167099a 100644 --- a/universal-refiner/src/history/schema.ts +++ b/universal-refiner/src/history/schema.ts @@ -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, + 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, diff --git a/universal-refiner/tests/acceptance/mcp-tools.acceptance.test.ts b/universal-refiner/tests/acceptance/mcp-tools.acceptance.test.ts index 0f7c3bd..e28580e 100644 --- a/universal-refiner/tests/acceptance/mcp-tools.acceptance.test.ts +++ b/universal-refiner/tests/acceptance/mcp-tools.acceptance.test.ts @@ -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"); @@ -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." }, @@ -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); }); diff --git a/universal-refiner/tests/blackboard.test.ts b/universal-refiner/tests/blackboard.test.ts index dcd9b0c..3c14287 100644 --- a/universal-refiner/tests/blackboard.test.ts +++ b/universal-refiner/tests/blackboard.test.ts @@ -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); }); }); diff --git a/universal-refiner/tests/history.test.ts b/universal-refiner/tests/history.test.ts index 07256c4..68476af 100644 --- a/universal-refiner/tests/history.test.ts +++ b/universal-refiner/tests/history.test.ts @@ -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({ diff --git a/universal-refiner/tests/ports.test.ts b/universal-refiner/tests/ports.test.ts new file mode 100644 index 0000000..70d914a --- /dev/null +++ b/universal-refiner/tests/ports.test.ts @@ -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}`, + ); + }); +}); diff --git a/universal-refiner/tests/setup.ts b/universal-refiner/tests/setup.ts new file mode 100644 index 0000000..6eb18cf --- /dev/null +++ b/universal-refiner/tests/setup.ts @@ -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"); diff --git a/universal-refiner/tests/stress/event-store.stress.test.ts b/universal-refiner/tests/stress/event-store.stress.test.ts index fb6b075..2c89045 100644 --- a/universal-refiner/tests/stress/event-store.stress.test.ts +++ b/universal-refiner/tests/stress/event-store.stress.test.ts @@ -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 }; diff --git a/universal-refiner/vitest.config.ts b/universal-refiner/vitest.config.ts index a3cc3c0..6d4327b 100644 --- a/universal-refiner/vitest.config.ts +++ b/universal-refiner/vitest.config.ts @@ -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"],