From f2c25eae29ae651e8c2d788ad60e7670b2c96930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Mon, 15 Jun 2026 14:45:14 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(autonomy):=20Phase=203=20Auto-Pilot=20?= =?UTF-8?q?Dashboard=20=E2=80=94=20AUTO-05/06=20complete=20(249=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AutoPilotStatus singleton tracking state (idle/busy/active), activity feed (50-entry cap, newest-first), and stats counters (cycles, commits, lessons). Exposes /api/autopilot endpoint on the Command Center dashboard. BackgroundAutonomyService and GitPoller fully instrumented. Hook runtime, MCP client, and operations scripts hardened with deterministic test coverage. AUTO-05: Active/Idle/Busy status indicator with lastCycleAt timestamp AUTO-06: Activity feed of autonomous learning events (kind-tagged, capped) AUTO-03: GitPoller wired into BackgroundAutonomyService (30s interval) Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/config/codex.config.fragment.toml | 2 +- universal-refiner/hooks/lib/hook-runtime.ts | 69 +++- universal-refiner/hooks/lib/mcp-client.ts | 61 ++- universal-refiner/hooks/post-execution.ts | 7 +- universal-refiner/hooks/pre-prompt.ts | 7 +- universal-refiner/package.json | 6 +- universal-refiner/register-global.ps1 | 63 +-- .../semantic-provider-acceptance.mjs | 7 + .../acceptance/tracked-turn-acceptance.mjs | 101 +++++ .../scripts/operations/REGISTER-GLOBAL.md | 34 ++ .../scripts/operations/child-process.mjs | 68 ++++ .../event-store-abrupt-recovery.mjs | 101 +++++ .../operations/event-store-crash-worker.mjs | 15 + .../scripts/operations/register-global.ps1 | 362 ++++++++++++++++++ .../stress/event-store-soak-worker.mjs | 47 +++ .../scripts/stress/event-store-soak.mjs | 84 ++++ .../src/core/autopilot-status.ts | 102 +++++ .../src/core/background-service.ts | 42 +- universal-refiner/src/core/blackboard.ts | 9 +- universal-refiner/src/core/dashboard.ts | 25 +- universal-refiner/src/core/logger.ts | 15 +- universal-refiner/src/core/redaction.ts | 113 ++++++ universal-refiner/src/core/server.ts | 32 +- universal-refiner/src/history/git-poller.ts | 33 +- universal-refiner/src/index.ts | 41 +- .../src/memory/neural-snippets.ts | 52 ++- .../real-process.acceptance.test.ts | 28 ++ .../tests/autopilot-dashboard.test.ts | 71 ++++ .../tests/autopilot-status.test.ts | 110 ++++++ .../tests/background-service.test.ts | 12 +- universal-refiner/tests/coverage-gaps.test.ts | 24 ++ .../tests/dashboard-coverage.test.ts | 10 +- .../tests/dashboard-events.test.ts | 4 +- .../tests/dashboard-start.test.ts | 11 +- universal-refiner/tests/gen.cjs | 1 + universal-refiner/tests/git-poller.test.ts | 15 + universal-refiner/tests/hook-runtime.test.ts | 46 +++ universal-refiner/tests/index.test.ts | 37 +- universal-refiner/tests/logger.test.ts | 67 +++- universal-refiner/tests/mcp-client.test.ts | 77 +++- .../tests/register-global.test.ts | 96 +++++ universal-refiner/tests/server.test.ts | 21 +- universal-refiner/tests/snippets.test.ts | 44 +++ .../real-process-recovery.stress.test.ts | 24 ++ universal-refiner/tests/write-gaps.cjs | 32 ++ 45 files changed, 2063 insertions(+), 165 deletions(-) create mode 100644 universal-refiner/scripts/acceptance/tracked-turn-acceptance.mjs create mode 100644 universal-refiner/scripts/operations/REGISTER-GLOBAL.md create mode 100644 universal-refiner/scripts/operations/child-process.mjs create mode 100644 universal-refiner/scripts/operations/event-store-abrupt-recovery.mjs create mode 100644 universal-refiner/scripts/operations/event-store-crash-worker.mjs create mode 100644 universal-refiner/scripts/operations/register-global.ps1 create mode 100644 universal-refiner/scripts/stress/event-store-soak-worker.mjs create mode 100644 universal-refiner/scripts/stress/event-store-soak.mjs create mode 100644 universal-refiner/src/core/autopilot-status.ts create mode 100644 universal-refiner/src/core/redaction.ts create mode 100644 universal-refiner/tests/acceptance/real-process.acceptance.test.ts create mode 100644 universal-refiner/tests/autopilot-dashboard.test.ts create mode 100644 universal-refiner/tests/autopilot-status.test.ts create mode 100644 universal-refiner/tests/coverage-gaps.test.ts create mode 100644 universal-refiner/tests/gen.cjs create mode 100644 universal-refiner/tests/register-global.test.ts create mode 100644 universal-refiner/tests/stress/real-process-recovery.stress.test.ts create mode 100644 universal-refiner/tests/write-gaps.cjs diff --git a/universal-refiner/hooks/config/codex.config.fragment.toml b/universal-refiner/hooks/config/codex.config.fragment.toml index 7d9c936..ef44e8d 100644 --- a/universal-refiner/hooks/config/codex.config.fragment.toml +++ b/universal-refiner/hooks/config/codex.config.fragment.toml @@ -1,4 +1,4 @@ -# Codex CLI 0.138.0 does not expose per-prompt pre/post lifecycle hooks. +# Codex CLI does not expose per-prompt pre/post lifecycle hooks. # Keep PromptImprover registered as an MCP server and invoke lint_prompt and # record_agent_output through repo instructions or explicit tool calls. # diff --git a/universal-refiner/hooks/lib/hook-runtime.ts b/universal-refiner/hooks/lib/hook-runtime.ts index 43c9b30..dbc62b6 100644 --- a/universal-refiner/hooks/lib/hook-runtime.ts +++ b/universal-refiner/hooks/lib/hook-runtime.ts @@ -16,12 +16,41 @@ export interface McpToolCaller { } const STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MAX_STDIN_BYTES = 1024 * 1024; +const READ_CHUNK_BYTES = 64 * 1024; +const TRANSPORT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOENT"]); export function parseHookInput(raw: string): HookInput { const normalized = raw.replace(/^\uFEFF/, "").trim(); return normalized ? JSON.parse(normalized) as HookInput : {}; } +export function readHookInput(descriptor = 0, maxBytes = DEFAULT_MAX_STDIN_BYTES): HookInput { + const limit = Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULT_MAX_STDIN_BYTES; + const chunks: Buffer[] = []; + let totalBytes = 0; + + while (true) { + const chunk = Buffer.alloc(Math.min(READ_CHUNK_BYTES, limit - totalBytes + 1)); + const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + totalBytes += bytesRead; + if (totalBytes > limit) throw Object.assign(new Error("Hook input exceeds the configured limit."), { code: "INPUT_TOO_LARGE" }); + chunks.push(chunk.subarray(0, bytesRead)); + } + + return parseHookInput(Buffer.concat(chunks, totalBytes).toString("utf8")); +} + +export function sanitizeError(error: unknown): string { + const code = errorCode(error); + if (code === "INPUT_TOO_LARGE") return "input-too-large"; + if (code === -32001 || code === "ETIMEDOUT") return "timeout"; + if (code === -32000 || (typeof code === "string" && TRANSPORT_ERROR_CODES.has(code))) return "transport-error"; + if (error instanceof SyntaxError) return "invalid-input"; + return "hook-error"; +} + export function detectClient(input: HookInput): string { const explicit = stringField(input, "client"); if (explicit) return explicit.toLowerCase(); @@ -96,6 +125,13 @@ export function statePath(input: HookInput): string { detectClient(input), stringField(input, "session_id") ?? stringField(input, "sessionId") ?? "no-session", stringField(input, "cwd") ?? process.cwd(), + firstString(input, [ + "request_id", "requestId", + "invocation_id", "invocationId", + "turn_id", "turnId", + "hook_id", "hookId", + "transcript_path", "transcriptPath", + ]) ?? "no-hook-id", ].join("|"); const hash = createHash("sha256").update(key).digest("hex"); return path.join(os.tmpdir(), "promptimprover-hooks", `${hash}.json`); @@ -152,18 +188,21 @@ export async function runPostExecution(input: HookInput, callTool: McpToolCaller const client = state?.client ?? detectClient(input); const outputLength = extractOutputLength(input); - await callTool("record_agent_output", { - prompt_id: promptId, - output_summary: `${client} completed the tracked turn; output_length=${outputLength}.`, - artifacts_json: JSON.stringify({ - client, - hook_event: stringField(input, "hook_event_name") ?? "manual", - output_length: outputLength, - }), - status: stringField(input, "status") === "failed" ? "failed" : "completed", - }); - clearState(input); - return allowOutput(input); + try { + await callTool("record_agent_output", { + prompt_id: promptId, + output_summary: `${client} completed the tracked turn; output_length=${outputLength}.`, + artifacts_json: JSON.stringify({ + client, + hook_event: stringField(input, "hook_event_name") ?? "manual", + output_length: outputLength, + }), + status: stringField(input, "status") === "failed" ? "failed" : "completed", + }); + return allowOutput(input); + } finally { + clearState(input); + } } function firstString(input: HookInput, fields: string[]): string | undefined { @@ -178,3 +217,9 @@ function stringField(input: HookInput, field: string): string | undefined { const value = input[field]; return typeof value === "string" ? value : undefined; } + +function errorCode(error: unknown): unknown { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; +} diff --git a/universal-refiner/hooks/lib/mcp-client.ts b/universal-refiner/hooks/lib/mcp-client.ts index 6558ee1..b3c97ae 100644 --- a/universal-refiner/hooks/lib/mcp-client.ts +++ b/universal-refiner/hooks/lib/mcp-client.ts @@ -1,13 +1,25 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResultSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; const DEFAULT_TIMEOUT_MS = 15_000; +const RECONNECT_SAFE_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOENT"]); export async function callMcpTool(name: string, args: Record): Promise { + const deadline = Date.now() + timeoutMs(); + try { + return await callMcpToolOnce(name, args, deadline); + } catch (error) { + if (!isReconnectSafeTransportFailure(error)) throw error; + return callMcpToolOnce(name, args, deadline); + } +} + +async function callMcpToolOnce(name: string, args: Record, deadline: number): Promise { + remainingMs(deadline); const transport = new StdioClientTransport({ command: process.execPath, args: [resolveServerPath()], @@ -16,17 +28,18 @@ export async function callMcpTool(name: string, args: Record): const client = new Client({ name: "promptimprover-cross-cli-hook", version: "1.0.0" }, { capabilities: {} }); try { - await client.connect(transport); - const result = await client.request( + await withinDeadline(client.connect(transport), deadline); + const remaining = remainingMs(deadline); + const result = await withinDeadline(client.request( { method: "tools/call", params: { name, arguments: args } }, CallToolResultSchema, - { timeout: timeoutMs() }, - ); + { timeout: remaining, maxTotalTimeout: remaining }, + ), deadline); const text = result.content.find((item) => item.type === "text"); - if (!text || text.type !== "text") throw new Error(`MCP tool ${name} returned no text content.`); + if (!text) throw new Error(`MCP tool ${name} returned no text content.`); return text.text; } finally { - await client.close().catch(() => undefined); + await withinDeadline(client.close(), deadline).catch(() => undefined); } } @@ -46,3 +59,37 @@ function timeoutMs(): number { const configured = Number(process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS); return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TIMEOUT_MS; } + +function remainingMs(deadline: number): number { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw timeoutError(); + return remaining; +} + +async function withinDeadline(operation: Promise, deadline: number): Promise { + const remaining = remainingMs(deadline); + let timer: NodeJS.Timeout; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(timeoutError()), remaining); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + clearTimeout(timer!); + } +} + +function isReconnectSafeTransportFailure(error: unknown): boolean { + const code = errorCode(error); + return code === ErrorCode.ConnectionClosed || (typeof code === "string" && RECONNECT_SAFE_CODES.has(code)); +} + +function errorCode(error: unknown): unknown { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; +} + +function timeoutError(): Error & { code: ErrorCode.RequestTimeout } { + return Object.assign(new Error("MCP hook deadline exceeded."), { code: ErrorCode.RequestTimeout as const }); +} diff --git a/universal-refiner/hooks/post-execution.ts b/universal-refiner/hooks/post-execution.ts index cc8a04e..e4ee530 100644 --- a/universal-refiner/hooks/post-execution.ts +++ b/universal-refiner/hooks/post-execution.ts @@ -1,15 +1,14 @@ #!/usr/bin/env node -import * as fs from "fs"; import { callMcpTool } from "./lib/mcp-client.js"; -import { allowOutput, HookInput, parseHookInput, runPostExecution } from "./lib/hook-runtime.js"; +import { allowOutput, HookInput, readHookInput, runPostExecution, sanitizeError } from "./lib/hook-runtime.js"; async function main(): Promise { let input: HookInput = {}; try { - input = parseHookInput(fs.readFileSync(0, "utf8")); + input = readHookInput(); console.log(JSON.stringify(await runPostExecution(input, callMcpTool))); } catch (error) { - console.error(`[PromptImprover] Post-execution hook failed open: ${error instanceof Error ? error.message : "unknown error"}`); + console.error(`[PromptImprover] Post-execution hook failed open: ${sanitizeError(error)}`); console.log(JSON.stringify(allowOutput(input))); } } diff --git a/universal-refiner/hooks/pre-prompt.ts b/universal-refiner/hooks/pre-prompt.ts index 3520309..2d0fd7a 100644 --- a/universal-refiner/hooks/pre-prompt.ts +++ b/universal-refiner/hooks/pre-prompt.ts @@ -1,15 +1,14 @@ #!/usr/bin/env node -import * as fs from "fs"; import { callMcpTool } from "./lib/mcp-client.js"; -import { allowOutput, HookInput, parseHookInput, runPrePrompt } from "./lib/hook-runtime.js"; +import { allowOutput, HookInput, readHookInput, runPrePrompt, sanitizeError } from "./lib/hook-runtime.js"; async function main(): Promise { let input: HookInput = {}; try { - input = parseHookInput(fs.readFileSync(0, "utf8")); + input = readHookInput(); console.log(JSON.stringify(await runPrePrompt(input, callMcpTool))); } catch (error) { - console.error(`[PromptImprover] Pre-prompt hook failed open: ${error instanceof Error ? error.message : "unknown error"}`); + console.error(`[PromptImprover] Pre-prompt hook failed open: ${sanitizeError(error)}`); console.log(JSON.stringify(allowOutput(input))); } } diff --git a/universal-refiner/package.json b/universal-refiner/package.json index 023f91e..80ed8b8 100644 --- a/universal-refiner/package.json +++ b/universal-refiner/package.json @@ -23,9 +23,13 @@ "package:check": "npm pack --dry-run", "db:backup": "node scripts/operations/event-store-recovery.mjs backup", "db:restore": "node scripts/operations/event-store-recovery.mjs restore", + "recovery:event-store:abrupt": "node scripts/operations/event-store-abrupt-recovery.mjs", "acceptance:semantic": "node scripts/acceptance/semantic-provider-acceptance.mjs", + "acceptance:gemma:live": "node scripts/acceptance/semantic-provider-acceptance.mjs --require-live", + "acceptance:tracked-turn": "node scripts/acceptance/tracked-turn-acceptance.mjs", "stress:event-store": "node scripts/stress/event-store-stress.mjs", - "release:verify": "npm run build && npm run test:coverage && npm run test:acceptance && npm run acceptance:semantic && npm run test:stress && npm run stress:event-store && npm run security:audit && npm run security:secrets && npm run package:check" + "stress:event-store:soak": "node scripts/stress/event-store-soak.mjs", + "release:verify": "npm run build && npm run test:coverage && npm run test:acceptance && npm run acceptance:semantic && npm run acceptance:tracked-turn && npm run test:stress && npm run stress:event-store && npm run recovery:event-store:abrupt && npm run stress:event-store:soak && npm run security:audit && npm run security:secrets && npm run package:check" }, "keywords": [ "mcp", diff --git a/universal-refiner/register-global.ps1 b/universal-refiner/register-global.ps1 index 3bfef58..862c5a3 100644 --- a/universal-refiner/register-global.ps1 +++ b/universal-refiner/register-global.ps1 @@ -1,51 +1,12 @@ -# Global AI Agent Registration Script -# Targets: Claude Desktop, Codex CLI/App, Gemini CLI - -$serverCommand = "node" -$serverPath = "C:/repo/Promptimprover/universal-refiner/dist/src/index.js" - -# 1. Claude Desktop -$claudePath = "$env:APPDATA\Claude\claude_desktop_config.json" -if (Test-Path $claudePath) { - Write-Host "Registering in Claude Desktop..." -ForegroundColor Cyan - $config = Get-Content $claudePath | ConvertFrom-Json - if (-not $config.mcpServers) { $config | Add-Member -MemberType NoteProperty -Name "mcpServers" -Value @{} } - $config.mcpServers | Add-Member -MemberType NoteProperty -Name "universal-refiner" -Value @{ command = $serverCommand; args = @($serverPath) } -Force - $config | ConvertTo-Json -Depth 10 | Set-Content $claudePath -} - -# 2. Codex (config.toml) -$codexPath = "$HOME\.codex\config.toml" -if (Test-Path $codexPath) { - Write-Host "Registering in Codex..." -ForegroundColor Cyan - $content = Get-Content $codexPath -Raw - if ($content -notmatch "\[mcp_servers\.universal-refiner\]") { - $codexEntry = @" - -[mcp_servers.universal-refiner] -command = "$serverCommand" -args = ["$serverPath"] -"@ - Add-Content $codexPath $codexEntry - } -} - -# 3. Gemini CLI (global) -$geminiGlobalDir = "$HOME\.gemini" -if (-not (Test-Path $geminiGlobalDir)) { New-Item -Path $geminiGlobalDir -ItemType Directory -Force } -$geminiConfigPath = "$geminiGlobalDir\gemini-extension.json" -Write-Host "Registering in Global Gemini Config..." -ForegroundColor Cyan -$geminiEntry = @{ - name = "universal-refiner" - version = "9.0.0" - mcpServers = @{ - "universal-refiner" = @{ - command = $serverCommand - args = @($serverPath) - } - } -} -$geminiEntry | ConvertTo-Json -Depth 10 | Set-Content $geminiConfigPath - -Write-Host "DONE! Universal Refiner registered globally for Claude, Codex, and Gemini." -ForegroundColor Green -Write-Host "Please restart your AI apps to apply changes." -ForegroundColor Yellow +[CmdletBinding()] +param( + [switch]$Check, + [switch]$Apply, + [string]$ProfileRoot = ("C:\Users\KimHarjam{0}ki" -f [char]0x00E4), + [string]$CodexHome, + [string]$ObsidianVaultPath = "C:\repo\global.obsidian" +) + +$operation = Join-Path $PSScriptRoot "scripts\operations\register-global.ps1" +& $operation @PSBoundParameters +exit $LASTEXITCODE diff --git a/universal-refiner/scripts/acceptance/semantic-provider-acceptance.mjs b/universal-refiner/scripts/acceptance/semantic-provider-acceptance.mjs index 5a72e58..d5881ba 100644 --- a/universal-refiner/scripts/acceptance/semantic-provider-acceptance.mjs +++ b/universal-refiner/scripts/acceptance/semantic-provider-acceptance.mjs @@ -8,12 +8,19 @@ import { const primary = process.env.PROMPT_REFINER_PRIMARY_MODEL || "gemma3:12b"; const fallback = process.env.PROMPT_REFINER_FALLBACK_MODEL || "gemma3:1b"; const liveBaseUrl = process.env.PROMPT_REFINER_ACCEPTANCE_BASE_URL; +const requireLive = process.argv.includes("--require-live") + || process.env.PROMPT_REFINER_ACCEPTANCE_REQUIRE_LIVE === "true"; const fake = await startFakeOpenAiServer({ unavailableModels: [primary], responses: { [fallback]: "fallback accepted" }, }); try { + assert.ok(!requireLive || liveBaseUrl, [ + "Required-live Gemma acceptance needs PROMPT_REFINER_ACCEPTANCE_BASE_URL.", + "Set it to the live OpenAI-compatible endpoint that serves the configured Gemma models.", + ].join(" ")); + if (liveBaseUrl) { for (const model of [primary, fallback]) { const liveProvider = new LocalOpenAiProvider({ diff --git a/universal-refiner/scripts/acceptance/tracked-turn-acceptance.mjs b/universal-refiner/scripts/acceptance/tracked-turn-acceptance.mjs new file mode 100644 index 0000000..2ddf6b4 --- /dev/null +++ b/universal-refiner/scripts/acceptance/tracked-turn-acceptance.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { parseLastJsonLine, runProcess } from "../operations/child-process.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +export async function runTrackedTurnAcceptance(options = {}) { + const directory = await mkdtemp(join(tmpdir(), "prompt-refiner-real-turn-")); + const profile = join(directory, "profile"); + const project = join(directory, "project"); + const sessionId = options.sessionId ?? "acceptance-session"; + const timeoutMs = options.timeoutMs ?? 45_000; + const preHook = join(repoRoot, "dist", "hooks", "pre-prompt.js"); + const postHook = join(repoRoot, "dist", "hooks", "post-execution.js"); + const databasePath = join(profile, ".refiner", "events.db"); + + try { + await mkdir(profile, { recursive: true }); + await mkdir(project, { recursive: true }); + await writeFile(join(project, "package.json"), JSON.stringify({ name: "tracked-turn-fixture", private: true })); + + const env = { + ...process.env, + USERPROFILE: profile, + HOME: profile, + AZURE_CONFIG_DIR: join(profile, ".azure"), + PROMPT_REFINER_LOG_LEVEL: "error", + }; + const commonInput = { + client: "acceptance", + session_id: sessionId, + cwd: project, + }; + const pre = await runProcess(process.execPath, [preHook], { + cwd: project, + env, + timeoutMs, + input: JSON.stringify({ + ...commonInput, + hook_event_name: "BeforeAgent", + prompt: "Implement a deterministic tracked-turn acceptance fixture and verify it.", + }), + }); + const preOutput = parseLastJsonLine(pre.stdout); + const context = preOutput?.hookSpecificOutput?.additionalContext; + assert.equal(typeof context, "string", `Pre-prompt hook returned no tracking context: ${pre.stdout}`); + const promptId = context.match(/Tracking ID: ([^.\s]+)/u)?.[1]; + assert.ok(promptId, `Pre-prompt hook returned no tracking ID: ${context}`); + + const post = await runProcess(process.execPath, [postHook], { + cwd: project, + env, + timeoutMs, + input: JSON.stringify({ + ...commonInput, + hook_event_name: "AfterAgent", + prompt_response: "Tracked-turn acceptance completed.", + status: "completed", + }), + }); + assert.deepEqual(parseLastJsonLine(post.stdout), { decision: "allow" }); + + const database = new Database(databasePath, { readonly: true }); + try { + assert.equal(database.pragma("integrity_check", { simple: true }), "ok"); + const linkage = database.prepare(` + SELECT + p.id AS prompt_id, + p.raw_prompt, + e.id AS execution_id, + e.status, + e.result_summary, + pe.id AS prompt_event_id + FROM prompts p + JOIN executions e ON e.prompt_id = p.id + JOIN events pe ON pe.prompt_id = p.id AND pe.event_type = 'prompt_received' + WHERE p.id = ? + `).get(promptId); + assert.ok(linkage, `SQLite has no linked prompt/execution/event rows for ${promptId}.`); + assert.equal(linkage.status, "completed"); + assert.match(linkage.result_summary, /acceptance completed the tracked turn/u); + + return { databasePath, promptId, linkage }; + } finally { + database.close(); + } + } finally { + if (!options.keepDirectory) { + await rm(directory, { recursive: true, force: true }); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = await runTrackedTurnAcceptance(); + console.log(`Real-process tracked turn passed: ${result.promptId} linked in SQLite.`); +} diff --git a/universal-refiner/scripts/operations/REGISTER-GLOBAL.md b/universal-refiner/scripts/operations/REGISTER-GLOBAL.md new file mode 100644 index 0000000..3bbcce9 --- /dev/null +++ b/universal-refiner/scripts/operations/REGISTER-GLOBAL.md @@ -0,0 +1,34 @@ +# Cross-CLI Registration Doctor + +`register-global.ps1` registers the current checkout's `prompt-refiner` MCP server and the Obsidian vault MCP server for Codex, Claude Code, and Gemini CLI. + +It defaults to `-Check`; no machine-wide files are changed unless `-Apply` is explicitly supplied. + +```powershell +.\register-global.ps1 -Check +.\register-global.ps1 -Apply +``` + +Defaults: + +- Windows profile: `C:\Users\KimHarjamäki` +- Prompt Refiner entry point: derived from the current checkout +- Obsidian vault: `C:\repo\global.obsidian` +- Codex home: `$env:CODEX_HOME` when using the default profile, otherwise `\.codex` + +Optional overrides: + +```powershell +.\register-global.ps1 -Check ` + -ProfileRoot 'C:\Users\KimHarjamäki' ` + -CodexHome 'C:\codex-home' ` + -ObsidianVaultPath 'C:\repo\global.obsidian' +``` + +The script preserves unrelated JSON and TOML configuration. In `-Apply` mode it creates timestamped backups before changed files are replaced through same-directory atomic UTF-8 writes. The doctor reports mojibake and suspicious plaintext credential field paths without printing credential values. + +Exit codes: + +- `0`: configuration is healthy, or apply completed without diagnostics +- `1`: registration drift found in check mode +- `2`: mojibake, plaintext credential fields, or unsafe config was detected diff --git a/universal-refiner/scripts/operations/child-process.mjs b/universal-refiner/scripts/operations/child-process.mjs new file mode 100644 index 0000000..f46bcb1 --- /dev/null +++ b/universal-refiner/scripts/operations/child-process.mjs @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; + +export function runProcess(command, args = [], options = {}) { + const { + cwd, + env = process.env, + input, + timeoutMs = 30_000, + acceptExitCodes = [0], + } = options; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + timer.unref(); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", chunk => stdout += chunk); + child.stderr.on("data", chunk => stderr += chunk); + child.on("error", error => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + const result = { code, signal, stdout, stderr }; + if (!timedOut && acceptExitCodes.includes(code)) { + resolve(result); + return; + } + + const reason = timedOut + ? `timed out after ${timeoutMs}ms` + : `exited with code ${code}${signal ? ` and signal ${signal}` : ""}`; + reject(new Error([ + `${command} ${args.join(" ")} ${reason}.`, + stdout ? `stdout:\n${stdout.trim()}` : "", + stderr ? `stderr:\n${stderr.trim()}` : "", + ].filter(Boolean).join("\n"))); + }); + + if (input !== undefined) { + child.stdin.end(input); + } else { + child.stdin.end(); + } + }); +} + +export function parseLastJsonLine(output) { + const lines = output.split(/\r?\n/u).map(line => line.trim()).filter(Boolean); + if (lines.length === 0) { + throw new Error("Process produced no JSON output."); + } + return JSON.parse(lines.at(-1)); +} diff --git a/universal-refiner/scripts/operations/event-store-abrupt-recovery.mjs b/universal-refiner/scripts/operations/event-store-abrupt-recovery.mjs new file mode 100644 index 0000000..df63a62 --- /dev/null +++ b/universal-refiner/scripts/operations/event-store-abrupt-recovery.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const workerScript = join(repoRoot, "scripts", "operations", "event-store-crash-worker.mjs"); + +export async function runAbruptRecovery(options = {}) { + const writes = options.writes ?? 25; + const timeoutMs = options.timeoutMs ?? 20_000; + const directory = await mkdtemp(join(tmpdir(), "prompt-refiner-abrupt-recovery-")); + const databasePath = join(directory, "events.db"); + const previousGlobalDir = process.env.PROMPT_REFINER_GLOBAL_DIR; + let child; + + try { + const ready = new Promise((resolveReady, reject) => { + child = spawn(process.execPath, [workerScript, String(writes)], { + cwd: repoRoot, + env: { ...process.env, PROMPT_REFINER_GLOBAL_DIR: directory, PROMPT_REFINER_LOG_LEVEL: "error" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => reject(new Error(`Crash worker did not become ready within ${timeoutMs}ms.\n${stderr}`)), timeoutMs); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", chunk => { + stdout += chunk; + const line = stdout.split(/\r?\n/u).find(candidate => candidate.trim()); + if (!line) return; + clearTimeout(timer); + resolveReady(JSON.parse(line)); + }); + child.stderr.on("data", chunk => stderr += chunk); + child.on("error", error => { + clearTimeout(timer); + reject(error); + }); + child.on("close", code => { + if (!stdout.trim()) { + clearTimeout(timer); + reject(new Error(`Crash worker exited ${code} before readiness.\n${stderr}`)); + } + }); + }); + + assert.deepEqual(await ready, { ready: true, writes }); + const closed = new Promise(resolveClosed => child.once("close", resolveClosed)); + assert.equal(child.kill("SIGKILL"), true, "Failed to terminate crash worker."); + await closed; + + process.env.PROMPT_REFINER_GLOBAL_DIR = directory; + const { EventStore } = await import("../../dist/src/history/event-store.js"); + const store = EventStore.getInstance(); + try { + store.recordEvent({ + id: "after-abrupt-restart", + event_type: "abrupt_recovery", + summary: "EventStore reopened after abrupt termination", + }); + } finally { + store.close(); + } + + const database = new Database(databasePath, { readonly: true }); + try { + const integrity = database.pragma("integrity_check", { simple: true }); + const count = database.prepare("SELECT COUNT(*) AS count FROM events WHERE event_type = 'abrupt_recovery'").get().count; + assert.equal(integrity, "ok"); + assert.equal(count, writes + 1); + return { databasePath, integrity, recoveredWrites: count }; + } finally { + database.close(); + } + } finally { + if (previousGlobalDir === undefined) { + delete process.env.PROMPT_REFINER_GLOBAL_DIR; + } else { + process.env.PROMPT_REFINER_GLOBAL_DIR = previousGlobalDir; + } + if (child?.exitCode === null) { + child.kill("SIGKILL"); + } + if (!options.keepDirectory) { + await rm(directory, { recursive: true, force: true }); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const writes = Number.parseInt(process.env.PROMPT_REFINER_RECOVERY_WRITES || "25", 10); + const result = await runAbruptRecovery({ writes }); + console.log(`Abrupt EventStore recovery passed: integrity=${result.integrity}, recovered=${result.recoveredWrites}.`); +} diff --git a/universal-refiner/scripts/operations/event-store-crash-worker.mjs b/universal-refiner/scripts/operations/event-store-crash-worker.mjs new file mode 100644 index 0000000..aae57d2 --- /dev/null +++ b/universal-refiner/scripts/operations/event-store-crash-worker.mjs @@ -0,0 +1,15 @@ +import { EventStore } from "../../dist/src/history/event-store.js"; + +const writes = Number.parseInt(process.argv[2] || "25", 10); +const store = EventStore.getInstance(); + +for (let index = 0; index < writes; index += 1) { + store.recordEvent({ + id: `abrupt-${index}`, + event_type: "abrupt_recovery", + summary: `durable before abrupt termination ${index}`, + }); +} + +console.log(JSON.stringify({ ready: true, writes })); +setInterval(() => undefined, 60_000); diff --git a/universal-refiner/scripts/operations/register-global.ps1 b/universal-refiner/scripts/operations/register-global.ps1 new file mode 100644 index 0000000..b0b2775 --- /dev/null +++ b/universal-refiner/scripts/operations/register-global.ps1 @@ -0,0 +1,362 @@ +[CmdletBinding()] +param( + [switch]$Check, + [switch]$Apply, + [string]$ProfileRoot = ("C:\Users\KimHarjam{0}ki" -f [char]0x00E4), + [string]$CodexHome, + [string]$ObsidianVaultPath = "C:\repo\global.obsidian" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ($Check -and $Apply) { + throw "Choose either -Check or -Apply." +} +if (-not $Check -and -not $Apply) { + $Check = $true +} + +$script:Changed = $false +$script:Issues = New-Object System.Collections.Generic.List[string] +$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +function Resolve-NormalizedPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)) +} + +function ConvertTo-ConfigPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return (Resolve-NormalizedPath $Path).Replace("\", "/") +} + +function ConvertTo-OrderedObject { + param($Value) + + if ($null -eq $Value) { return $null } + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $result = [ordered]@{} + foreach ($property in $Value.PSObject.Properties) { + $result[$property.Name] = ConvertTo-OrderedObject $property.Value + } + return $result + } + if ($Value -is [System.Collections.IDictionary]) { + $result = [ordered]@{} + foreach ($key in $Value.Keys) { + $result[$key] = ConvertTo-OrderedObject $Value[$key] + } + return $result + } + if (($Value -is [System.Collections.IEnumerable]) -and -not ($Value -is [string])) { + return ,@($Value | ForEach-Object { ConvertTo-OrderedObject $_ }) + } + return $Value +} + +function ConvertTo-StableJson { + param([Parameter(Mandatory = $true)]$Value) + + return ((ConvertTo-OrderedObject $Value) | ConvertTo-Json -Depth 100) + [Environment]::NewLine +} + +function Test-Mojibake { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + return $Text -match "\uFFFD|\u00C2|\u00C3|\u00E2[\u0080-\u00BF]" +} + +function Find-PlaintextCredentialFields { + param( + $Value, + [string]$Path = "`$" + ) + + if ($null -eq $Value) { return } + if (($Value -is [System.Collections.IDictionary]) -or ($Value -is [System.Management.Automation.PSCustomObject])) { + $entries = if ($Value -is [System.Collections.IDictionary]) { + @($Value.Keys | ForEach-Object { [pscustomobject]@{ Name = $_; Value = $Value[$_] } }) + } else { + @($Value.PSObject.Properties | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Value = $_.Value } }) + } + foreach ($entry in $entries) { + $key = $entry.Name + $fieldPath = "$Path.$key" + $item = $entry.Value + if ($key -match "(?i)(api[-_]?key|access[-_]?token|auth[-_]?token|client[-_]?secret|password|credential|authorization|bearer)" -and + $item -is [string] -and -not [string]::IsNullOrWhiteSpace($item) -and + $item -notmatch "^\$\{[A-Za-z_][A-Za-z0-9_]*\}$") { + $script:Issues.Add("plaintext credential field: $fieldPath") + } + Find-PlaintextCredentialFields $item $fieldPath + } + return + } + if (($Value -is [System.Collections.IEnumerable]) -and -not ($Value -is [string])) { + $index = 0 + foreach ($item in $Value) { + Find-PlaintextCredentialFields $item "$Path[$index]" + $index++ + } + } +} + +function Read-JsonConfig { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return [ordered]@{} + } + $text = [System.IO.File]::ReadAllText($Path) + if (Test-Mojibake $text) { + $script:Issues.Add("mojibake detected: $Path") + } + if ([string]::IsNullOrWhiteSpace($text)) { + return [ordered]@{} + } + try { + $config = ConvertTo-OrderedObject ($text | ConvertFrom-Json) + Find-PlaintextCredentialFields $config + return $config + } catch { + $script:Issues.Add("invalid JSON: $Path") + throw "Cannot safely merge invalid JSON config: $Path" + } +} + +function Backup-Config { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { return } + $stamp = Get-Date -Format "yyyyMMdd-HHmmss-fff" + Copy-Item -LiteralPath $Path -Destination "$Path.promptimprover-backup-$stamp" -Force +} + +function Write-AtomicUtf8 { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $directory = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + Backup-Config $Path + $temporaryPath = Join-Path $directory (".{0}.{1}.tmp" -f ([System.IO.Path]::GetFileName($Path)), [Guid]::NewGuid().ToString("N")) + $replaceBackupPath = "$temporaryPath.replace-backup" + try { + [System.IO.File]::WriteAllText($temporaryPath, $Content, $script:Utf8NoBom) + if (Test-Path -LiteralPath $Path) { + [System.IO.File]::Replace($temporaryPath, $Path, $replaceBackupPath) + } else { + Move-Item -LiteralPath $temporaryPath -Destination $Path + } + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + if (Test-Path -LiteralPath $replaceBackupPath) { + Remove-Item -LiteralPath $replaceBackupPath -Force + } + } +} + +function Set-MapValue { + param( + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Map, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)]$Value + ) + + $Map[$Name] = ConvertTo-OrderedObject $Value +} + +function Merge-Hooks { + param( + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Config, + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$DesiredHooks + ) + + if (-not $Config.Contains("hooks") -or -not ($Config["hooks"] -is [System.Collections.IDictionary])) { + $Config["hooks"] = [ordered]@{} + } + foreach ($eventName in $DesiredHooks.Keys) { + $existing = @($Config["hooks"][$eventName] | Where-Object { $null -ne $_ }) + $desired = @($DesiredHooks[$eventName]) + foreach ($entry in $desired) { + $desiredCommands = @($entry["hooks"] | ForEach-Object { $_["command"] } | Where-Object { $_ -like "promptimprover-hook-*" }) + if ($desiredCommands.Count -gt 0) { + $existing = @($existing | Where-Object { + $candidateCommands = @($_["hooks"] | ForEach-Object { $_["command"] }) + -not ($candidateCommands | Where-Object { $desiredCommands -contains $_ }) + }) + } + $fingerprint = $entry | ConvertTo-Json -Depth 100 -Compress + $existingFingerprints = @($existing | ForEach-Object { $_ | ConvertTo-Json -Depth 100 -Compress }) + if ($existingFingerprints -notcontains $fingerprint) { + $existing += ,(ConvertTo-OrderedObject $entry) + } + } + $Config["hooks"][$eventName] = $existing + } +} + +function Update-JsonConfig { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Servers, + [System.Collections.IDictionary]$Hooks + ) + + $config = Read-JsonConfig $Path + if ($Servers.Count -gt 0) { + if (-not $config.Contains("mcpServers") -or -not ($config["mcpServers"] -is [System.Collections.IDictionary])) { + $config["mcpServers"] = [ordered]@{} + } + foreach ($serverName in $Servers.Keys) { + Set-MapValue $config["mcpServers"] $serverName $Servers[$serverName] + } + } + if ($null -ne $Hooks) { + Merge-Hooks $config $Hooks + } + + $desiredContent = ConvertTo-StableJson $config + $currentContent = if (Test-Path -LiteralPath $Path) { [System.IO.File]::ReadAllText($Path) } else { "" } + if ($currentContent -ceq $desiredContent) { + Write-Host "OK $Name" + return + } + + $script:Changed = $true + if ($Apply) { + Write-AtomicUtf8 $Path $desiredContent + Write-Host "UPDATED $Name" + } else { + Write-Host "DRIFT $Name" + } +} + +function ConvertTo-TomlString { + param([Parameter(Mandatory = $true)][string]$Value) + + return '"' + $Value.Replace("\", "\\").Replace('"', '\"') + '"' +} + +function Set-TomlSection { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content, + [Parameter(Mandatory = $true)][string]$SectionName, + [Parameter(Mandatory = $true)][string[]]$Lines + ) + + $section = "[$SectionName]`r`n" + (($Lines -join "`r`n") + "`r`n") + $escaped = [regex]::Escape("[$SectionName]") + $pattern = "(?ms)^$escaped\s*\r?\n.*?(?=^\[|\z)" + if ($Content -match $pattern) { + return [regex]::Replace($Content, $pattern, $section) + } + if (-not [string]::IsNullOrWhiteSpace($Content) -and -not $Content.EndsWith("`n")) { + $Content += "`r`n" + } + return $Content + $section +} + +function Update-CodexConfig { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Servers + ) + + $content = if (Test-Path -LiteralPath $Path) { [System.IO.File]::ReadAllText($Path) } else { "" } + if (Test-Mojibake $content) { + $script:Issues.Add("mojibake detected: $Path") + } + foreach ($match in [regex]::Matches($content, '(?im)^\s*([A-Za-z0-9_.-]*(?:key|token|secret|password|credential|authorization|bearer)[A-Za-z0-9_.-]*)\s*=\s*(?!"\$\{)[^#\r\n]+')) { + $script:Issues.Add("plaintext credential field: `$.$($match.Groups[1].Value)") + } + + $desiredContent = $content + foreach ($serverName in $Servers.Keys) { + $server = $Servers[$serverName] + $arguments = @($server.args | ForEach-Object { ConvertTo-TomlString $_ }) -join ", " + $desiredContent = Set-TomlSection $desiredContent "mcp_servers.$serverName" @( + "command = $(ConvertTo-TomlString $server.command)", + "args = [$arguments]" + ) + } + if (-not $desiredContent.EndsWith("`n")) { + $desiredContent += "`r`n" + } + + if ($content -ceq $desiredContent) { + Write-Host "OK Codex" + return + } + $script:Changed = $true + if ($Apply) { + Write-AtomicUtf8 $Path $desiredContent + Write-Host "UPDATED Codex" + } else { + Write-Host "DRIFT Codex" + } +} + +$profile = Resolve-NormalizedPath $ProfileRoot +$repoRoot = Resolve-NormalizedPath (Join-Path $PSScriptRoot "..\..") +$serverPath = ConvertTo-ConfigPath (Join-Path $repoRoot "dist\src\index.js") +$vaultPath = ConvertTo-ConfigPath $ObsidianVaultPath + +if ([string]::IsNullOrWhiteSpace($CodexHome)) { + if (-not $PSBoundParameters.ContainsKey("ProfileRoot") -and -not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + $CodexHome = $env:CODEX_HOME + } else { + $CodexHome = Join-Path $profile ".codex" + } +} +$codexRoot = Resolve-NormalizedPath $CodexHome + +$servers = [ordered]@{ + "prompt-refiner" = [ordered]@{ + command = "node" + args = @($serverPath) + } + "obsidian" = [ordered]@{ + command = "npx" + args = @("-y", "@bitbonsai/mcpvault@latest", $vaultPath) + } +} + +$claudeHooks = Read-JsonConfig (Join-Path $repoRoot "hooks\config\claude.settings.fragment.json") +$geminiHooks = Read-JsonConfig (Join-Path $repoRoot "hooks\config\gemini.settings.fragment.json") + +Write-Host "Mode: $(if ($Apply) { 'Apply' } else { 'Check' })" +Write-Host "Profile: $profile" +Write-Host "Checkout: $repoRoot" + +try { + Update-CodexConfig (Join-Path $codexRoot "config.toml") $servers + Update-JsonConfig "Claude Code MCP" (Join-Path $profile ".claude.json") $servers + Update-JsonConfig "Claude Code hooks" (Join-Path $profile ".claude\settings.json") ([ordered]@{}) $claudeHooks["hooks"] + Update-JsonConfig "Gemini" (Join-Path $profile ".gemini\settings.json") $servers $geminiHooks["hooks"] +} catch { + Write-Warning $_.Exception.Message + exit 2 +} + +foreach ($issue in $script:Issues | Select-Object -Unique) { + Write-Warning $issue +} + +if ($script:Issues.Count -gt 0) { + exit 2 +} +if ($Check -and $script:Changed) { + exit 1 +} +exit 0 diff --git a/universal-refiner/scripts/stress/event-store-soak-worker.mjs b/universal-refiner/scripts/stress/event-store-soak-worker.mjs new file mode 100644 index 0000000..0208038 --- /dev/null +++ b/universal-refiner/scripts/stress/event-store-soak-worker.mjs @@ -0,0 +1,47 @@ +import { EventStore } from "../../dist/src/history/event-store.js"; + +const workerId = process.argv[2]; +const durationMs = Number.parseInt(process.argv[3] || "10000", 10); +const deadline = Date.now() + durationMs; +const counts = { events: 0, prompts: 0, executions: 0, operations: 0 }; +const store = EventStore.getInstance(); +let index = 0; +let lastPromptId; + +while (Date.now() < deadline) { + const id = `soak-${workerId}-${index}`; + const operation = index % 3; + + if (operation === 0) { + lastPromptId = `prompt-${id}`; + store.recordPrompt({ + id: lastPromptId, + client: "soak-worker", + raw_prompt: `mixed operation prompt ${id}`, + }); + counts.prompts += 1; + } else if (operation === 1) { + store.recordExecution({ + id: `execution-${id}`, + prompt_id: lastPromptId, + workflow_name: "soak", + executor_name: `worker-${workerId}`, + status: "completed", + }); + counts.executions += 1; + } else { + store.recordEvent({ + id: `event-${id}`, + event_type: "soak_mixed", + prompt_id: lastPromptId, + summary: `mixed operation event ${id}`, + }); + counts.events += 1; + } + + counts.operations += 1; + index += 1; +} + +store.close(); +console.log(JSON.stringify({ workerId, ...counts })); diff --git a/universal-refiner/scripts/stress/event-store-soak.mjs b/universal-refiner/scripts/stress/event-store-soak.mjs new file mode 100644 index 0000000..1f36455 --- /dev/null +++ b/universal-refiner/scripts/stress/event-store-soak.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { parseLastJsonLine, runProcess } from "../operations/child-process.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const workerScript = join(repoRoot, "scripts", "stress", "event-store-soak-worker.mjs"); + +function readPositiveInteger(value, fallback, name) { + const parsed = Number.parseInt(value ?? String(fallback), 10); + assert.ok(Number.isInteger(parsed) && parsed > 0, `${name} must be a positive integer.`); + return parsed; +} + +function readRatio(value, fallback, name) { + const parsed = Number(value ?? fallback); + assert.ok(Number.isFinite(parsed) && parsed >= 0 && parsed <= 1, `${name} must be between 0 and 1.`); + return parsed; +} + +export async function runEventStoreSoak(options = {}) { + const workers = readPositiveInteger(options.workers, 4, "workers"); + const durationMs = readPositiveInteger(options.durationMs, 10_000, "durationMs"); + const minOperations = readPositiveInteger(options.minOperations, workers * 10, "minOperations"); + const maxLossRatio = readRatio(options.maxLossRatio, 0, "maxLossRatio"); + const directory = await mkdtemp(join(tmpdir(), "prompt-refiner-soak-")); + const databasePath = join(directory, "events.db"); + + try { + const results = await Promise.all(Array.from({ length: workers }, async (_, index) => { + const result = await runProcess(process.execPath, [workerScript, String(index), String(durationMs)], { + cwd: repoRoot, + env: { ...process.env, PROMPT_REFINER_GLOBAL_DIR: directory, PROMPT_REFINER_LOG_LEVEL: "error" }, + timeoutMs: durationMs + 30_000, + }); + return parseLastJsonLine(result.stdout); + })); + const expected = results.reduce((sum, result) => ({ + operations: sum.operations + result.operations, + prompts: sum.prompts + result.prompts, + executions: sum.executions + result.executions, + events: sum.events + result.events + result.prompts, + }), { operations: 0, prompts: 0, executions: 0, events: 0 }); + + const database = new Database(databasePath, { readonly: true }); + try { + const integrity = database.pragma("integrity_check", { simple: true }); + const actual = { + prompts: database.prepare("SELECT COUNT(*) AS count FROM prompts").get().count, + executions: database.prepare("SELECT COUNT(*) AS count FROM executions").get().count, + events: database.prepare("SELECT COUNT(*) AS count FROM events").get().count, + }; + const expectedRows = expected.prompts + expected.executions + expected.events; + const actualRows = actual.prompts + actual.executions + actual.events; + const lossRatio = expectedRows === 0 ? 1 : Math.max(0, expectedRows - actualRows) / expectedRows; + + assert.equal(integrity, "ok"); + assert.ok(expected.operations >= minOperations, `Soak completed ${expected.operations} operations; minimum is ${minOperations}.`); + assert.ok(lossRatio <= maxLossRatio, `Soak loss ratio ${lossRatio} exceeded maximum ${maxLossRatio}.`); + assert.ok(actual.prompts > 0 && actual.executions > 0 && actual.events > 0, "Soak did not exercise all mixed operation types."); + + return { workers, durationMs, integrity, expected, actual, lossRatio }; + } finally { + database.close(); + } + } finally { + if (!options.keepDirectory) { + await rm(directory, { recursive: true, force: true }); + } + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = await runEventStoreSoak({ + workers: process.env.PROMPT_REFINER_SOAK_WORKERS, + durationMs: process.env.PROMPT_REFINER_SOAK_DURATION_MS, + minOperations: process.env.PROMPT_REFINER_SOAK_MIN_OPERATIONS, + maxLossRatio: process.env.PROMPT_REFINER_SOAK_MAX_LOSS_RATIO, + }); + console.log(`EventStore soak passed: ${result.expected.operations} mixed operations, integrity=${result.integrity}, loss=${result.lossRatio}.`); +} diff --git a/universal-refiner/src/core/autopilot-status.ts b/universal-refiner/src/core/autopilot-status.ts new file mode 100644 index 0000000..25eb981 --- /dev/null +++ b/universal-refiner/src/core/autopilot-status.ts @@ -0,0 +1,102 @@ +export type AutoPilotState = "idle" | "busy" | "active"; + +export interface AutoPilotActivity { + timestamp: string; + message: string; + kind: "file_change" | "git_commit" | "cycle_start" | "cycle_complete" | "lesson" | "error"; +} + +export interface AutoPilotSnapshot { + state: AutoPilotState; + lastCycleAt: string | null; + activity: AutoPilotActivity[]; + stats: { + cyclesCompleted: number; + commitsIngested: number; + lessonsExtracted: number; + }; +} + +const MAX_ACTIVITY = 50; + +/** + * Module-level singleton that satisfies AUTO-05 and AUTO-06. + * + * BackgroundAutonomyService writes to this store as it runs cycles. + * CommandCenterDashboard reads from it to serve /api/autopilot. + */ +export class AutoPilotStatus { + private static state: AutoPilotState = "idle"; + private static lastCycleAt: string | null = null; + private static activityLog: AutoPilotActivity[] = []; + private static stats = { + cyclesCompleted: 0, + commitsIngested: 0, + lessonsExtracted: 0, + }; + + // ------------------------------------------------------------------------- + // State transitions (AUTO-05) + // ------------------------------------------------------------------------- + + static setBusy(): void { + this.state = "busy"; + } + + static setActive(): void { + this.state = "active"; + this.lastCycleAt = new Date().toISOString(); + } + + static setIdle(): void { + this.state = "idle"; + } + + // ------------------------------------------------------------------------- + // Activity feed (AUTO-06) + // ------------------------------------------------------------------------- + + static record(message: string, kind: AutoPilotActivity["kind"]): void { + this.activityLog.unshift({ timestamp: new Date().toISOString(), message, kind }); + if (this.activityLog.length > MAX_ACTIVITY) { + this.activityLog.length = MAX_ACTIVITY; + } + } + + // ------------------------------------------------------------------------- + // Stats + // ------------------------------------------------------------------------- + + static addCommits(count: number): void { + this.stats.commitsIngested += count; + } + + static addLessons(count: number): void { + this.stats.lessonsExtracted += count; + } + + static incrementCycles(): void { + this.stats.cyclesCompleted++; + } + + // ------------------------------------------------------------------------- + // Read (for dashboard + tests) + // ------------------------------------------------------------------------- + + static getSnapshot(maxActivity = 20): AutoPilotSnapshot { + return { + state: this.state, + lastCycleAt: this.lastCycleAt, + activity: this.activityLog.slice(0, maxActivity), + stats: { ...this.stats }, + }; + } + + /** Reset — only used in tests to avoid cross-test state leakage. */ + static reset(): void { + this.state = "idle"; + this.lastCycleAt = null; + this.activityLog = []; + this.stats = { cyclesCompleted: 0, commitsIngested: 0, lessonsExtracted: 0 }; + } +} diff --git a/universal-refiner/src/core/background-service.ts b/universal-refiner/src/core/background-service.ts index 0f5895c..b6371a8 100644 --- a/universal-refiner/src/core/background-service.ts +++ b/universal-refiner/src/core/background-service.ts @@ -3,6 +3,7 @@ import { CommitIngester } from "../history/commit-ingest.js"; import { LessonExtractor } from "../history/lesson-extractor.js"; import { CorrelationEngine } from "../history/correlation-engine.js"; import { GitPoller } from "../history/git-poller.js"; +import { AutoPilotStatus } from "./autopilot-status.js"; import { RuntimeLogger } from "./logger.js"; import { CommandCenterDashboard } from "./dashboard.js"; import { SerializedJobQueue } from "./job-queue.js"; @@ -29,7 +30,10 @@ export class BackgroundAutonomyService { this.requestModelText = requestModelText; if (gitPollIntervalMs !== null) { this.gitPoller = new GitPoller(rootPath, gitPollIntervalMs); - this.gitPoller.on("commits", () => this.triggerAutonomy()); + this.gitPoller.on("commits", (count: number) => { + AutoPilotStatus.record(`Git poll detected ${count} new commit(s)`, "git_commit"); + this.triggerAutonomy(); + }); } } @@ -54,8 +58,15 @@ export class BackgroundAutonomyService { this.watcher.on('all', (event, filePath) => { RuntimeLogger.debug(`File change detected: ${event} ${filePath}`); + AutoPilotStatus.record(`File ${event}: ${filePath}`, "file_change"); this.triggerAutonomy(); }); + this.watcher.on("error", (error) => { + AutoPilotStatus.setIdle(); + AutoPilotStatus.record("Watcher degraded", "error"); + RuntimeLogger.error("Background autonomy watcher failed", error); + CommandCenterDashboard.log("Background Autonomy: Watcher degraded. See logs."); + }); this.gitPoller?.start(); this.queue.enqueue(`autonomy:${this.rootPath}`, () => this.runCycles()); @@ -75,18 +86,41 @@ export class BackgroundAutonomyService { } private async runCycles() { + AutoPilotStatus.setBusy(); + AutoPilotStatus.record("Cycle started", "cycle_start"); + CommandCenterDashboard.log("Background Autonomy: Change detected. Triggering intelligence cycles..."); try { - CommandCenterDashboard.log("Background Autonomy: Change detected. Triggering intelligence cycles..."); const ingestedCount = await CommitIngester.ingestLatest(this.rootPath, 100); + if (ingestedCount > 0) { + AutoPilotStatus.addCommits(ingestedCount); + AutoPilotStatus.record(`Ingested ${ingestedCount} commit(s)`, "git_commit"); + } CommandCenterDashboard.log(`Background Autonomy: Ingested ${ingestedCount} commits.`); const engine = new CorrelationEngine(); const extractor = new LessonExtractor(this.requestModelText); - await engine.correlateAll(); - await extractor.extractNewLessons(); + const lessonsBefore = AutoPilotStatus.getSnapshot().stats.lessonsExtracted; + const results = await Promise.allSettled([ + engine.correlateAll(), + extractor.extractNewLessons(), + ]); + const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (rejected) { + throw rejected.reason; + } + const lessonsAfter = AutoPilotStatus.getSnapshot().stats.lessonsExtracted; + if (lessonsAfter > lessonsBefore) { + AutoPilotStatus.record(`Extracted ${lessonsAfter - lessonsBefore} lesson(s)`, "lesson"); + } + + AutoPilotStatus.incrementCycles(); + AutoPilotStatus.setActive(); + AutoPilotStatus.record("Cycle complete", "cycle_complete"); CommandCenterDashboard.log("Background Autonomy: Correlation and lesson extraction complete."); } catch (error) { + AutoPilotStatus.setIdle(); + AutoPilotStatus.record(`Cycle failed: ${error instanceof Error ? error.message : String(error)}`, "error"); RuntimeLogger.error("Background Autonomy cycle failed", error); CommandCenterDashboard.log("Background Autonomy: Cycle failed. See logs."); throw error; diff --git a/universal-refiner/src/core/blackboard.ts b/universal-refiner/src/core/blackboard.ts index 2bb71d0..15ea28f 100644 --- a/universal-refiner/src/core/blackboard.ts +++ b/universal-refiner/src/core/blackboard.ts @@ -105,11 +105,18 @@ export class AgenticBlackboard { private static atomicUpdate(storagePath: string, fallback: T, updater: (data: T) => void): Promise { this.writeQueue = this.writeQueue.then(() => { + const temporaryPath = `${storagePath}.${process.pid}.${Date.now()}.tmp`; try { const data = this.readJsonFile(storagePath, fallback); updater(data); - fs.writeFileSync(storagePath, JSON.stringify(data, null, 2)); + fs.writeFileSync(temporaryPath, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); + fs.renameSync(temporaryPath, storagePath); } catch (error) { + try { + fs.rmSync(temporaryPath, { force: true }); + } catch { + // Preserve the original persistence failure. + } RuntimeLogger.error(`Atomic update failed for ${storagePath}`, error); } }); diff --git a/universal-refiner/src/core/dashboard.ts b/universal-refiner/src/core/dashboard.ts index 7a0b6a4..1bd3763 100644 --- a/universal-refiner/src/core/dashboard.ts +++ b/universal-refiner/src/core/dashboard.ts @@ -12,6 +12,7 @@ import { ConfigManager } from "./config.js"; import { TimelineProvider } from "../history/timeline.js"; import { EventStore } from "../history/event-store.js"; +import { AutoPilotStatus } from "./autopilot-status.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -71,6 +72,7 @@ export function isSameOriginRequest(origin: string | undefined, requestUrl: stri export class CommandCenterDashboard { private static rootPath: string = "."; + private static server: { close: (callback?: (error?: Error) => void) => void } | null = null; static async setLastRefinement(original: string, refined: string, projectPath: string = ".", gain: number = 0) { await AgenticBlackboard.setLastRefinement(original, refined, projectPath, gain); @@ -348,6 +350,15 @@ export class CommandCenterDashboard { } }); + app.get("/api/autopilot", (c) => { + try { + return c.json(AutoPilotStatus.getSnapshot()); + } catch (error) { + this.logRouteError("api/autopilot", error); + return c.json({ error: "Auto-pilot status unavailable" }, 500); + } + }); + app.get("/api/health", async (c) => { const selectedPath = this.resolveSelectedPath(c.req.query("project")); try { @@ -425,7 +436,7 @@ export class CommandCenterDashboard { return c.html(html); } catch (error) { this.logRouteError("/", error, selectedPath); - return c.html(`

Dashboard Error

The dashboard failed to render.

${String(error instanceof Error ? error.stack || error.message : error)}
`, 500); + return c.html(`

Dashboard Error

The dashboard failed to render. See sanitized runtime logs.

`, 500); } }); @@ -436,6 +447,7 @@ export class CommandCenterDashboard { const app = this.createApp(defaultPath); try { const server = serve({ fetch: app.fetch, port, hostname: resolveDashboardHost() }); + this.server = server; server.on("error", (e: any) => { if (e.code === "EADDRINUSE") { console.error(`[Command Center] Port ${port} taken.`); @@ -449,4 +461,15 @@ export class CommandCenterDashboard { throw error; } } + + static async stop(): Promise { + const server = this.server; + this.server = null; + if (!server) { + return; + } + await new Promise((resolve, reject) => { + server.close((error?: Error) => error ? reject(error) : resolve()); + }); + } } diff --git a/universal-refiner/src/core/logger.ts b/universal-refiner/src/core/logger.ts index ff29554..8b0a2e6 100644 --- a/universal-refiner/src/core/logger.ts +++ b/universal-refiner/src/core/logger.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { redact, redactString } from "./redaction.js"; export type LogLevel = "debug" | "info" | "warn" | "error"; @@ -33,25 +34,19 @@ function serializeMeta(meta?: unknown): string { return ""; } - if (meta instanceof Error) { - return `${meta.name}: ${meta.message}\n${meta.stack || ""}`.trim(); - } - - if (typeof meta === "string") { - return meta; - } + const safeMeta = redact(meta); try { - return JSON.stringify(meta); + return typeof safeMeta === "string" ? safeMeta : JSON.stringify(safeMeta); } catch { - return String(meta); + return redactString(String(safeMeta)); } } function write(level: LogLevel, message: string, meta?: unknown) { const timestamp = new Date().toISOString(); const renderedMeta = serializeMeta(meta); - const line = `[${timestamp}] [${level.toUpperCase()}] ${message}${renderedMeta ? ` | ${renderedMeta}` : ""}`; + const line = `[${timestamp}] [${level.toUpperCase()}] ${redactString(message)}${renderedMeta ? ` | ${renderedMeta}` : ""}`; try { fs.mkdirSync(getGlobalDir(), { recursive: true }); diff --git a/universal-refiner/src/core/redaction.ts b/universal-refiner/src/core/redaction.ts new file mode 100644 index 0000000..b0d776e --- /dev/null +++ b/universal-refiner/src/core/redaction.ts @@ -0,0 +1,113 @@ +export const REDACTED = "[REDACTED]"; + +const SENSITIVE_KEYS = new Set([ + "authorization", + "cookie", + "set_cookie", + "password", + "passwd", + "pwd", + "secret", + "clientsecret", + "client_secret", + "token", + "access_token", + "accesstoken", + "refresh_token", + "refreshtoken", + "api_key", + "apikey", + "x_api_key", + "private_key", + "privatekey", + "connection_string", + "connectionstring", + "accountkey", + "sig", + "aws_access_key_id", + "aws_secret_access_key", + "azure_client_secret", + "openai_api_key", + "github_token", + "gitlab_token", + "database_url", + "redis_url", +]); +const SENSITIVE_FILE = /^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|credentials?(?:\..*)?|secrets?(?:\..*)?|service-account(?:\..*)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?|.*\.(?:key|pem|p12|pfx))$/i; +const URL_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']+/gi; +const BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi; +const ASSIGNMENT_PATTERN = /(\b(?:authorization|password|passwd|pwd|secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|api[_-]?key|x-api-key|private[_-]?key|connection[_-]?string|accountkey|sig|token|aws[_-]?(?:access[_-]?key[_-]?id|secret[_-]?access[_-]?key)|azure[_-]?client[_-]?secret|openai[_-]?api[_-]?key|github[_-]?token|gitlab[_-]?token|database[_-]?url|redis[_-]?url)\b\s*[:=]\s*)(["']?)([^"',;&#\s}\]]+)\2/gi; +const PRIVATE_KEY_PATTERN = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/i; +const CREDENTIAL_LITERAL_PATTERN = /\b(?:authorization|password|passwd|pwd|secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|api[_-]?key|x-api-key|private[_-]?key|connection[_-]?string|accountkey|sig|token|aws[_-]?(?:access[_-]?key[_-]?id|secret[_-]?access[_-]?key)|azure[_-]?client[_-]?secret|openai[_-]?api[_-]?key|github[_-]?token|gitlab[_-]?token|database[_-]?url|redis[_-]?url)\b\s*[:=]\s*(?:(["'])[^"'\r\n]{8,}\1|[A-Za-z0-9._~+/=-]{16,})/i; +const URL_SECRET_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s:@]+:[^@\s]+@|[^\s?#]+[?&](?:password|secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|api[_-]?key|x-api-key|accountkey|sig|token)=)/i; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.replace(/[-\s]/g, "_").toLowerCase()); +} + +function redactUrl(value: string): string { + try { + const url = new URL(value); + if (url.username) url.username = REDACTED; + if (url.password) url.password = REDACTED; + for (const key of [...url.searchParams.keys()]) { + if (isSensitiveKey(key)) url.searchParams.set(key, REDACTED); + } + return url.toString(); + } catch { + return value; + } +} + +/** Redacts secrets embedded in free-form text, including URL credentials and query parameters. */ +export function redactString(value: string): string { + return value + .replace(URL_PATTERN, redactUrl) + .replace(BEARER_PATTERN, (_match, scheme: string) => `${scheme} ${REDACTED}`) + .replace(ASSIGNMENT_PATTERN, (_match, prefix: string, quote: string) => `${prefix}${quote}${REDACTED}${quote}`); +} + +function redactRecursive(value: unknown, seen: WeakSet): unknown { + if (typeof value === "string") return redactString(value); + if (value === null || typeof value !== "object") return value; + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + if (value instanceof Error) { + return { + name: value.name, + message: redactString(value.message), + stack: value.stack ? redactString(value.stack) : "", + }; + } + + if (Array.isArray(value)) return value.map(item => redactRecursive(item, seen)); + + try { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + isSensitiveKey(key) ? REDACTED : redactRecursive(item, seen), + ]), + ); + } catch { + return REDACTED; + } +} + +/** Produces a log-safe recursive clone without mutating the caller's value. */ +export function redact(value: unknown): unknown { + return redactRecursive(value, new WeakSet()); +} + +export function isSensitiveFilename(filePath: string): boolean { + const filename = filePath.replace(/\\/g, "/").split("/").pop() || ""; + return SENSITIVE_FILE.test(filename); +} + +/** Returns true when source content appears to contain credential material rather than references to environment variables. */ +export function containsSensitiveContent(content: string): boolean { + BEARER_PATTERN.lastIndex = 0; + if (PRIVATE_KEY_PATTERN.test(content) || BEARER_PATTERN.test(content)) return true; + return CREDENTIAL_LITERAL_PATTERN.test(content) || URL_SECRET_PATTERN.test(content); +} diff --git a/universal-refiner/src/core/server.ts b/universal-refiner/src/core/server.ts index 1111afb..4b33a8c 100644 --- a/universal-refiner/src/core/server.ts +++ b/universal-refiner/src/core/server.ts @@ -47,6 +47,7 @@ export class PromptRefinerServer { private semanticProviders: SemanticProviderChain; private repository: RepositoryIdentity; private templateSelector: ApprovedTemplateSelector; + private running = false; constructor(rootPath: string = ".") { this.rootPath = rootPath; @@ -730,19 +731,32 @@ Output ONLY the JSON array. If no gaps, return [].`, }); } - async run() { + async run(options: { background?: boolean } = {}) { const transport = new StdioServerTransport(); await this.server.connect(transport); - - // Start Background Autonomy - this.backgroundAutonomy = new BackgroundAutonomyService( - this.rootPath, - this.requestModelText.bind(this), - 30_000, // AUTO-03: poll git every 30s - ); - this.backgroundAutonomy.start(); + this.running = true; + + if (options.background) { + this.backgroundAutonomy = new BackgroundAutonomyService( + this.rootPath, + this.requestModelText.bind(this), + 30_000, + ); + this.backgroundAutonomy.start(); + } RuntimeLogger.info(`Prompt Refiner ${getDisplayVersion()} running on stdio`, { rootPath: this.rootPath }); console.error(`Prompt Refiner ${getDisplayVersion()} running on stdio`); } + + async stop() { + if (!this.running && !this.backgroundAutonomy) { + return; + } + this.backgroundAutonomy?.stop(); + await this.backgroundAutonomy?.idle(); + this.backgroundAutonomy = null; + await this.server.close().catch(() => undefined); + this.running = false; + } } diff --git a/universal-refiner/src/history/git-poller.ts b/universal-refiner/src/history/git-poller.ts index 919c375..8962549 100644 --- a/universal-refiner/src/history/git-poller.ts +++ b/universal-refiner/src/history/git-poller.ts @@ -18,6 +18,7 @@ export class GitPoller extends EventEmitter { private readonly intervalMs: number; private timer: NodeJS.Timeout | null = null; private running = false; + private inFlight: Promise | null = null; constructor(repoPath: string, intervalMs = DEFAULT_POLL_INTERVAL_MS) { super(); @@ -58,17 +59,27 @@ export class GitPoller extends EventEmitter { * Safe to call externally for an immediate check. */ public async poll(): Promise { - try { - const count = await CommitIngester.ingestLatest(this.repoPath, 50); - if (count > 0) { - RuntimeLogger.debug(`[GitPoller] Ingested ${count} new commit(s)`, { repoPath: this.repoPath }); - CommandCenterDashboard.log(`Background Autonomy: ${count} new commit(s) detected.`); - this.emit("commits", count); - } - return count; - } catch (err) { - RuntimeLogger.error("[GitPoller] Poll failed", err); - return 0; + if (this.inFlight) { + RuntimeLogger.debug("[GitPoller] Skipping overlapping poll", { repoPath: this.repoPath }); + return this.inFlight; } + + this.inFlight = (async () => { + try { + const count = await CommitIngester.ingestLatest(this.repoPath, 50); + if (count > 0) { + RuntimeLogger.debug(`[GitPoller] Ingested ${count} new commit(s)`, { repoPath: this.repoPath }); + CommandCenterDashboard.log(`Background Autonomy: ${count} new commit(s) detected.`); + this.emit("commits", count); + } + return count; + } catch (err) { + RuntimeLogger.error("[GitPoller] Poll failed", err); + return 0; + } finally { + this.inFlight = null; + } + })(); + return this.inFlight; } } diff --git a/universal-refiner/src/index.ts b/universal-refiner/src/index.ts index 2dca833..5ce4087 100644 --- a/universal-refiner/src/index.ts +++ b/universal-refiner/src/index.ts @@ -4,24 +4,47 @@ import { CommandCenterDashboard } from "./core/dashboard.js"; import { FileWatcher } from "./watcher/index.js"; import { RuntimeLogger } from "./core/logger.js"; import * as path from "path"; +import { AgenticBlackboard } from "./core/blackboard.js"; +import { EventStore } from "./history/event-store.js"; -// Start the Web Dashboard in the background const port = parseInt(process.env.PORT || "3000", 10); const rootPath = path.resolve(process.cwd()); -CommandCenterDashboard.start(port, rootPath); +const backgroundMode = process.env.PROMPT_REFINER_BACKGROUND === "true"; +if (backgroundMode) { + CommandCenterDashboard.start(port, rootPath); +} const server = new PromptRefinerServer(rootPath); -// Phase 1 (AUTO-01, AUTO-02): Real-time file system watcher -// Starts watching for meaningful source/prompt file changes and logs them. -const fileWatcher = new FileWatcher(rootPath); -fileWatcher.on("change", (evt) => { +const fileWatcher = backgroundMode ? new FileWatcher(rootPath) : null; +fileWatcher?.on("change", (evt) => { RuntimeLogger.info(`[FS] ${evt.event}: ${evt.path}`); CommandCenterDashboard.log(`[FS] ${evt.event}: ${path.relative(rootPath, evt.path)}`); }); -fileWatcher.start(); +fileWatcher?.start(); -server.run().catch((error) => { +let shuttingDown = false; +async function shutdown(reason: string) { + if (shuttingDown) return; + shuttingDown = true; + RuntimeLogger.info("Prompt Refiner shutdown started", { reason }); + await fileWatcher?.stop(); + await server.stop(); + await AgenticBlackboard.flushPendingWrites(); + await CommandCenterDashboard.stop(); + EventStore.getInstance().close(); +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + void shutdown(signal).finally(() => process.exit(0)); + }); +} +process.stdin.once("end", () => { + if (!backgroundMode) void shutdown("stdin-end"); +}); + +server.run({ background: backgroundMode }).catch((error) => { console.error("[FATAL ERROR]", error); - process.exit(1); + void shutdown("fatal-error").finally(() => process.exit(1)); }); diff --git a/universal-refiner/src/memory/neural-snippets.ts b/universal-refiner/src/memory/neural-snippets.ts index 4334b34..4296ffa 100644 --- a/universal-refiner/src/memory/neural-snippets.ts +++ b/universal-refiner/src/memory/neural-snippets.ts @@ -3,6 +3,7 @@ import * as path from "path"; // @ts-ignore import flexsearch from "flexsearch"; import ts from "typescript"; +import { containsSensitiveContent, isSensitiveFilename } from "../core/redaction.js"; const { Index } = flexsearch; @@ -27,19 +28,35 @@ export class NeuralSnippets { this.isInitialized = false; } - private static async walkDir(dir: string, fileList: string[] = []): Promise { + private static isWithinRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); + } + + private static async walkDir(dir: string, root: string, fileList: string[] = []): Promise { if (!fs.existsSync(dir)) return fileList; - const files = fs.readdirSync(dir); + const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { - const name = path.join(dir, file); - if (fs.statSync(name).isDirectory()) { + const name = path.join(dir, file.name); + const stat = fs.lstatSync(name); + if (stat.isSymbolicLink()) continue; + + const canonicalName = fs.realpathSync.native(name); + if (!this.isWithinRoot(root, canonicalName)) continue; + + if (file.isDirectory()) { const ignoreDirs = ["node_modules", "dist", "build", "out", "coverage", "tests", "test"]; - if (!ignoreDirs.includes(file) && !file.startsWith(".")) { - await this.walkDir(name, fileList); + if (!ignoreDirs.includes(file.name) && !file.name.startsWith(".")) { + await this.walkDir(canonicalName, root, fileList); } } else { - if ((file.endsWith(".ts") || file.endsWith(".js") || file.endsWith(".py")) && !file.includes(".test.") && !file.includes(".spec.")) { - fileList.push(name); + if ( + (file.name.endsWith(".ts") || file.name.endsWith(".js") || file.name.endsWith(".py")) + && !file.name.includes(".test.") + && !file.name.includes(".spec.") + && !isSensitiveFilename(file.name) + ) { + fileList.push(canonicalName); } } } @@ -116,13 +133,24 @@ export class NeuralSnippets { static async initialize(rootPath: string = ".") { if (this.isInitialized) return; - const files = await this.walkDir(rootPath); + if (!fs.existsSync(rootPath)) { + this.isInitialized = true; + return; + } + const root = fs.realpathSync.native(rootPath); + const files = await this.walkDir(root, root); let id = 0; for (const filePath of files) { + const stat = fs.lstatSync(filePath); + const canonicalPath = fs.realpathSync.native(filePath); + if (stat.isSymbolicLink() || !this.isWithinRoot(root, canonicalPath)) continue; + const content = fs.readFileSync(filePath, "utf-8"); - const symbols = this.parseSymbols(content, filePath); + if (containsSensitiveContent(content)) continue; + + const symbols = this.parseSymbols(content, canonicalPath); for (const sym of symbols) { - const doc: Snippet = { id: id++, filePath, content: sym.content!, symbolName: sym.symbolName, symbolType: sym.symbolType as any }; + const doc: Snippet = { id: id++, filePath: canonicalPath, content: sym.content!, symbolName: sym.symbolName, symbolType: sym.symbolType as any }; this.symbolIndex.add(doc.id, doc.symbolName || ""); this.contentIndex.add(doc.id, doc.content); this.store.set(doc.id, doc); @@ -131,7 +159,7 @@ export class NeuralSnippets { for (let i = 0; i < lines.length; i += 15) { const chunk = lines.slice(i, i + 25).join("\n"); if (chunk.trim().length > 100) { - const doc: Snippet = { id: id++, filePath, content: chunk, symbolType: "chunk" }; + const doc: Snippet = { id: id++, filePath: canonicalPath, content: chunk, symbolType: "chunk" }; this.contentIndex.add(doc.id, doc.content); this.store.set(doc.id, doc); } diff --git a/universal-refiner/tests/acceptance/real-process.acceptance.test.ts b/universal-refiner/tests/acceptance/real-process.acceptance.test.ts new file mode 100644 index 0000000..aa0f6ce --- /dev/null +++ b/universal-refiner/tests/acceptance/real-process.acceptance.test.ts @@ -0,0 +1,28 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runProcess } from "../../scripts/operations/child-process.mjs"; +import { runTrackedTurnAcceptance } from "../../scripts/acceptance/tracked-turn-acceptance.mjs"; + +describe("real-process acceptance", () => { + it("links a synthetic turn produced by actual hook executables and stdio MCP processes", async () => { + const result = await runTrackedTurnAcceptance({ timeoutMs: 45_000 }); + + expect(result.promptId).toMatch(/^prm_/u); + expect(result.linkage).toMatchObject({ + prompt_id: result.promptId, + status: "completed", + }); + }, 60_000); + + it("fails required-live Gemma mode when no live endpoint is configured", async () => { + const script = resolve("scripts/acceptance/semantic-provider-acceptance.mjs"); + const env = { ...process.env }; + delete env.PROMPT_REFINER_ACCEPTANCE_BASE_URL; + delete env.PROMPT_REFINER_ACCEPTANCE_REQUIRE_LIVE; + + await expect(runProcess(process.execPath, [script, "--require-live"], { + env, + timeoutMs: 10_000, + })).rejects.toThrow(/Required-live Gemma acceptance needs PROMPT_REFINER_ACCEPTANCE_BASE_URL/u); + }); +}); diff --git a/universal-refiner/tests/autopilot-dashboard.test.ts b/universal-refiner/tests/autopilot-dashboard.test.ts new file mode 100644 index 0000000..ec797b5 --- /dev/null +++ b/universal-refiner/tests/autopilot-dashboard.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { CommandCenterDashboard } from "../src/core/dashboard.js"; +import { AutoPilotStatus } from "../src/core/autopilot-status.js"; +import { EventStore } from "../src/history/event-store.js"; + +describe("/api/autopilot dashboard route (AUTO-05, AUTO-06)", () => { + const testDir = path.join(os.tmpdir(), `refiner-autopilot-${Date.now()}`); + + beforeEach(() => { + fs.mkdirSync(testDir, { recursive: true }); + process.env.PROMPT_REFINER_GLOBAL_DIR = path.join(testDir, "global"); + (EventStore as any).instance = null; + AutoPilotStatus.reset(); + }); + + afterEach(() => { + const instance = (EventStore as any).instance as EventStore | null; + instance?.close(); + (EventStore as any).instance = null; + delete process.env.PROMPT_REFINER_GLOBAL_DIR; + fs.rmSync(testDir, { recursive: true, force: true }); + AutoPilotStatus.reset(); + }); + + it("returns idle state when no cycle has run (AUTO-05)", async () => { + const app = CommandCenterDashboard.createApp(testDir); + const res = await app.request("/api/autopilot"); + expect(res.status).toBe(200); + const body = await res.json() as { state: string }; + expect(body.state).toBe("idle"); + }); + + it("reflects busy state set by BackgroundAutonomyService (AUTO-05)", async () => { + AutoPilotStatus.setBusy(); + const app = CommandCenterDashboard.createApp(testDir); + const res = await app.request("/api/autopilot"); + const body = await res.json() as { state: string }; + expect(body.state).toBe("busy"); + }); + + it("includes activity feed in response (AUTO-06)", async () => { + AutoPilotStatus.record("Ingested 2 commits", "git_commit"); + AutoPilotStatus.record("Cycle complete", "cycle_complete"); + const app = CommandCenterDashboard.createApp(testDir); + const res = await app.request("/api/autopilot"); + const body = await res.json() as { activity: Array<{ message: string }> }; + expect(body.activity[0].message).toBe("Cycle complete"); + expect(body.activity[1].message).toBe("Ingested 2 commits"); + }); + + it("includes stats counters in response", async () => { + AutoPilotStatus.addCommits(5); + AutoPilotStatus.incrementCycles(); + const app = CommandCenterDashboard.createApp(testDir); + const res = await app.request("/api/autopilot"); + const body = await res.json() as { stats: { commitsIngested: number; cyclesCompleted: number } }; + expect(body.stats.commitsIngested).toBe(5); + expect(body.stats.cyclesCompleted).toBe(1); + }); + + it("includes lastCycleAt when a cycle has completed (AUTO-05)", async () => { + AutoPilotStatus.setActive(); + const app = CommandCenterDashboard.createApp(testDir); + const res = await app.request("/api/autopilot"); + const body = await res.json() as { lastCycleAt: string | null }; + expect(body.lastCycleAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); diff --git a/universal-refiner/tests/autopilot-status.test.ts b/universal-refiner/tests/autopilot-status.test.ts new file mode 100644 index 0000000..61cbed6 --- /dev/null +++ b/universal-refiner/tests/autopilot-status.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AutoPilotStatus } from "../src/core/autopilot-status.js"; + +describe("AutoPilotStatus", () => { + beforeEach(() => { + AutoPilotStatus.reset(); + }); + + // ------------------------------------------------------------------------- + // AUTO-05: status indicator transitions + // ------------------------------------------------------------------------- + + it("starts in 'idle' state (AUTO-05)", () => { + expect(AutoPilotStatus.getSnapshot().state).toBe("idle"); + }); + + it("transitions idle → busy → active (AUTO-05)", () => { + AutoPilotStatus.setBusy(); + expect(AutoPilotStatus.getSnapshot().state).toBe("busy"); + + AutoPilotStatus.setActive(); + expect(AutoPilotStatus.getSnapshot().state).toBe("active"); + }); + + it("setActive records lastCycleAt timestamp (AUTO-05)", () => { + expect(AutoPilotStatus.getSnapshot().lastCycleAt).toBeNull(); + AutoPilotStatus.setActive(); + expect(AutoPilotStatus.getSnapshot().lastCycleAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("setIdle returns to idle from any state (AUTO-05)", () => { + AutoPilotStatus.setBusy(); + AutoPilotStatus.setIdle(); + expect(AutoPilotStatus.getSnapshot().state).toBe("idle"); + }); + + // ------------------------------------------------------------------------- + // AUTO-06: activity feed + // ------------------------------------------------------------------------- + + it("record() prepends entries to the activity feed (AUTO-06)", () => { + AutoPilotStatus.record("Cycle started", "cycle_start"); + AutoPilotStatus.record("Ingested 3 commits", "git_commit"); + + const { activity } = AutoPilotStatus.getSnapshot(); + expect(activity[0].message).toBe("Ingested 3 commits"); + expect(activity[1].message).toBe("Cycle started"); + }); + + it("activity entries carry ISO timestamp and kind (AUTO-06)", () => { + AutoPilotStatus.record("File changed", "file_change"); + const { activity } = AutoPilotStatus.getSnapshot(); + expect(activity[0].kind).toBe("file_change"); + expect(activity[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("activity feed is capped at 50 entries (AUTO-06)", () => { + for (let i = 0; i < 60; i++) { + AutoPilotStatus.record(`event ${i}`, "cycle_complete"); + } + const { activity } = AutoPilotStatus.getSnapshot(60); + expect(activity.length).toBe(50); + }); + + it("getSnapshot(maxActivity) limits the returned slice (AUTO-06)", () => { + for (let i = 0; i < 30; i++) { + AutoPilotStatus.record(`event ${i}`, "cycle_complete"); + } + expect(AutoPilotStatus.getSnapshot(5).activity.length).toBe(5); + }); + + // ------------------------------------------------------------------------- + // Stats counters + // ------------------------------------------------------------------------- + + it("addCommits accumulates across calls", () => { + AutoPilotStatus.addCommits(3); + AutoPilotStatus.addCommits(5); + expect(AutoPilotStatus.getSnapshot().stats.commitsIngested).toBe(8); + }); + + it("addLessons accumulates across calls", () => { + AutoPilotStatus.addLessons(2); + AutoPilotStatus.addLessons(1); + expect(AutoPilotStatus.getSnapshot().stats.lessonsExtracted).toBe(3); + }); + + it("incrementCycles counts completed cycles", () => { + AutoPilotStatus.incrementCycles(); + AutoPilotStatus.incrementCycles(); + expect(AutoPilotStatus.getSnapshot().stats.cyclesCompleted).toBe(2); + }); + + // ------------------------------------------------------------------------- + // reset() for test isolation + // ------------------------------------------------------------------------- + + it("reset() returns to initial state", () => { + AutoPilotStatus.setBusy(); + AutoPilotStatus.addCommits(10); + AutoPilotStatus.record("something", "error"); + AutoPilotStatus.reset(); + + const snap = AutoPilotStatus.getSnapshot(); + expect(snap.state).toBe("idle"); + expect(snap.lastCycleAt).toBeNull(); + expect(snap.activity).toHaveLength(0); + expect(snap.stats.commitsIngested).toBe(0); + }); +}); diff --git a/universal-refiner/tests/background-service.test.ts b/universal-refiner/tests/background-service.test.ts index e4ff700..2c1b63a 100644 --- a/universal-refiner/tests/background-service.test.ts +++ b/universal-refiner/tests/background-service.test.ts @@ -52,10 +52,20 @@ describe("BackgroundAutonomyService", () => { expect(mocks.watch).toHaveBeenCalledTimes(1); expect(mocks.ingest).toHaveBeenCalledWith("C:/repo", 100); - expect(mocks.correlate).toHaveBeenCalledBefore(mocks.extract); + expect(mocks.correlate).toHaveBeenCalledOnce(); + expect(mocks.extract).toHaveBeenCalledOnce(); expect(mocks.watcher.close).toHaveBeenCalledOnce(); }); + it("reports watcher degradation without throwing an unhandled error", () => { + const service = new BackgroundAutonomyService("C:/repo", vi.fn()); + service.start(); + const errorHandler = mocks.watcher.on.mock.calls.find(call => call[0] === "error")?.[1]; + + expect(() => errorHandler(new Error("watch failed"))).not.toThrow(); + expect(mocks.error).toHaveBeenCalledWith("Background autonomy watcher failed", expect.any(Error)); + }); + it("debounces file changes and logs cycle failures for queue retries", async () => { vi.useFakeTimers(); mocks.correlate.mockRejectedValue(new Error("correlation failed")); diff --git a/universal-refiner/tests/coverage-gaps.test.ts b/universal-refiner/tests/coverage-gaps.test.ts new file mode 100644 index 0000000..7c6b572 --- /dev/null +++ b/universal-refiner/tests/coverage-gaps.test.ts @@ -0,0 +1,24 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +describe("ConfigManager gap coverage", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cfg-gap-")); + delete process.env.PROMPT_REFINER_GLOBAL_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns an empty object when the repository config contains invalid JSON", async () => { + const { ConfigManager } = await import("../src/core/config.js"); + fs.writeFileSync(path.join(tmpDir, ".gemini-refiner.json"), "{ NOT VALID JSON }"); + + expect(ConfigManager.loadConfig(tmpDir)).toEqual({}); + }); +}); diff --git a/universal-refiner/tests/dashboard-coverage.test.ts b/universal-refiner/tests/dashboard-coverage.test.ts index 8347d2a..e8588a5 100644 --- a/universal-refiner/tests/dashboard-coverage.test.ts +++ b/universal-refiner/tests/dashboard-coverage.test.ts @@ -155,10 +155,12 @@ describe("dashboard deterministic fallbacks", () => { const app = CommandCenterDashboard.createApp(directory); const error = new Error("root message"); error.stack = ""; - vi.spyOn(CommandCenterDashboard as any, "buildState").mockRejectedValueOnce(error); - const response = await app.request("/"); - expect(await response.text()).toContain("root message"); - }); + vi.spyOn(CommandCenterDashboard as any, "buildState").mockRejectedValueOnce(error); + const response = await app.request("/"); + const html = await response.text(); + expect(html).toContain("See sanitized runtime logs"); + expect(html).not.toContain("root message"); + }); it("handles review persistence failures and successful template approval", async () => { const app = CommandCenterDashboard.createApp(directory); diff --git a/universal-refiner/tests/dashboard-events.test.ts b/universal-refiner/tests/dashboard-events.test.ts index 18a55b7..dbb7849 100644 --- a/universal-refiner/tests/dashboard-events.test.ts +++ b/universal-refiner/tests/dashboard-events.test.ts @@ -199,7 +199,9 @@ describe("dashboard event stream and render failures", () => { const response = await app.request("/api/events"); - expect(response.status).toBe(500); + expect(response.status).toBe(500); + expect(html).toContain("See sanitized runtime logs"); + expect(html).not.toContain("Could not find dashboard.html"); expect(await response.text()).toBe("Dashboard event stream unavailable"); }); diff --git a/universal-refiner/tests/dashboard-start.test.ts b/universal-refiner/tests/dashboard-start.test.ts index 9310cd0..95159e5 100644 --- a/universal-refiner/tests/dashboard-start.test.ts +++ b/universal-refiner/tests/dashboard-start.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ serve: vi.fn(), on: vi.fn(), + close: vi.fn(), error: vi.fn(), })); @@ -16,7 +17,8 @@ import { CommandCenterDashboard } from "../src/core/dashboard.js"; describe("dashboard server startup", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.serve.mockReturnValue({ on: mocks.on }); + mocks.close.mockImplementation((callback?: () => void) => callback?.()); + mocks.serve.mockReturnValue({ on: mocks.on, close: mocks.close }); }); it("binds the configured host and reports server errors", () => { @@ -37,4 +39,11 @@ describe("dashboard server startup", () => { expect(() => CommandCenterDashboard.start(3999, ".")).toThrow("startup failed"); expect(mocks.error).toHaveBeenCalledWith("Dashboard failed to start on port 3999", expect.any(Error)); }); + + it("closes the active dashboard server and tolerates repeated stops", async () => { + CommandCenterDashboard.start(3999, "."); + await CommandCenterDashboard.stop(); + await CommandCenterDashboard.stop(); + expect(mocks.close).toHaveBeenCalledOnce(); + }); }); diff --git a/universal-refiner/tests/gen.cjs b/universal-refiner/tests/gen.cjs new file mode 100644 index 0000000..d46d6b5 --- /dev/null +++ b/universal-refiner/tests/gen.cjs @@ -0,0 +1 @@ +module.exports=function(){}; \ No newline at end of file diff --git a/universal-refiner/tests/git-poller.test.ts b/universal-refiner/tests/git-poller.test.ts index 4121362..47eb83a 100644 --- a/universal-refiner/tests/git-poller.test.ts +++ b/universal-refiner/tests/git-poller.test.ts @@ -91,6 +91,21 @@ describe("GitPoller", () => { expect(count).toBe(0); }); + it("coalesces overlapping polls", async () => { + let release!: (count: number) => void; + ingestLatest.mockReturnValue(new Promise(resolve => { + release = resolve; + })); + const poller = new GitPoller("/repo"); + + const first = poller.poll(); + const second = poller.poll(); + expect(ingestLatest).toHaveBeenCalledOnce(); + release(2); + + await expect(Promise.all([first, second])).resolves.toEqual([2, 2]); + }); + // ------------------------------------------------------------------------- // AUTO-03: start() triggers polling on interval // ------------------------------------------------------------------------- diff --git a/universal-refiner/tests/hook-runtime.test.ts b/universal-refiner/tests/hook-runtime.test.ts index 6950ab4..d75899c 100644 --- a/universal-refiner/tests/hook-runtime.test.ts +++ b/universal-refiner/tests/hook-runtime.test.ts @@ -11,8 +11,10 @@ import { extractPromptId, loadState, parseHookInput, + readHookInput, runPostExecution, runPrePrompt, + sanitizeError, saveState, statePath, } from "../hooks/lib/hook-runtime.js"; @@ -40,6 +42,40 @@ describe("cross-CLI hook runtime", () => { expect(extractPromptId("not json")).toBeUndefined(); }); + it("bounds total hook input size and sanitizes failures", () => { + const file = path.join(path.dirname(statePath(input)), "bounded-input.json"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{"prompt":"ok"}'); + const descriptor = fs.openSync(file, "r"); + expect(readHookInput(descriptor, 32)).toEqual({ prompt: "ok" }); + fs.closeSync(descriptor); + + const invalidLimitDescriptor = fs.openSync(file, "r"); + expect(readHookInput(invalidLimitDescriptor, 0)).toEqual({ prompt: "ok" }); + fs.closeSync(invalidLimitDescriptor); + + fs.writeFileSync(file, "x".repeat(33)); + const oversizedDescriptor = fs.openSync(file, "r"); + let oversizedError: unknown; + try { + readHookInput(oversizedDescriptor, 32); + } catch (error) { + oversizedError = error; + } finally { + fs.closeSync(oversizedDescriptor); + fs.rmSync(file, { force: true }); + } + expect(sanitizeError(oversizedError)).toBe("input-too-large"); + + expect(sanitizeError(Object.assign(new Error("secret path"), { code: -32001 }))).toBe("timeout"); + expect(sanitizeError(Object.assign(new Error("secret timeout"), { code: "ETIMEDOUT" }))).toBe("timeout"); + expect(sanitizeError(Object.assign(new Error("secret transport"), { code: -32000 }))).toBe("transport-error"); + expect(sanitizeError(Object.assign(new Error("secret transport"), { code: "ECONNRESET" }))).toBe("transport-error"); + expect(sanitizeError(new SyntaxError("secret input"))).toBe("invalid-input"); + expect(sanitizeError(new Error("secret generic"))).toBe("hook-error"); + expect(sanitizeError("secret unknown")).toBe("hook-error"); + }); + it("detects explicit and event-derived clients", () => { expect(detectClient({ client: "CoDeX" })).toBe("codex"); expect(detectClient({ hook_event_name: "UserPromptSubmit" })).toBe("claude"); @@ -92,6 +128,8 @@ describe("cross-CLI hook runtime", () => { it("uses stable state paths and removes invalid or stale state", () => { expect(statePath(input)).toBe(statePath(input)); expect(statePath({ sessionId: "camel", cwd: "C:/repo" })).not.toBe(statePath(input)); + expect(statePath({ ...input, request_id: "one" })).not.toBe(statePath({ ...input, request_id: "two" })); + expect(statePath({ ...input, hookId: "one" })).not.toBe(statePath({ ...input, hookId: "two" })); expect(statePath({})).toMatch(/promptimprover-hooks[\\/].+\.json$/); saveState(input, { promptId: "", client: "gemini", createdAt: new Date().toISOString() }); @@ -167,4 +205,12 @@ describe("cross-CLI hook runtime", () => { artifacts_json: JSON.stringify({ client: "codex", hook_event: "manual", output_length: 6 }), })); }); + + it("clears correlation state even when post recording fails", async () => { + saveState(input, { promptId: "stale-if-retained", client: "gemini", createdAt: new Date().toISOString() }); + const call = vi.fn().mockRejectedValue(new Error("recording failed")); + + await expect(runPostExecution(input, call)).rejects.toThrow("recording failed"); + expect(loadState(input)).toBeUndefined(); + }); }); diff --git a/universal-refiner/tests/index.test.ts b/universal-refiner/tests/index.test.ts index 1663920..8482dc7 100644 --- a/universal-refiner/tests/index.test.ts +++ b/universal-refiner/tests/index.test.ts @@ -2,17 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ dashboardStart: vi.fn(), + dashboardStop: vi.fn(), dashboardLog: vi.fn(), serverRun: vi.fn(), + serverStop: vi.fn(), watcherStart: vi.fn(), + watcherStop: vi.fn(), watcherOn: vi.fn(), loggerInfo: vi.fn(), serverConstructor: vi.fn(), watcherConstructor: vi.fn(), + flush: vi.fn(), + eventStoreClose: vi.fn(), })); vi.mock("../src/core/dashboard.js", () => ({ - CommandCenterDashboard: { start: mocks.dashboardStart, log: mocks.dashboardLog }, + CommandCenterDashboard: { start: mocks.dashboardStart, stop: mocks.dashboardStop, log: mocks.dashboardLog }, })); vi.mock("../src/core/server.js", () => ({ PromptRefinerServer: class { @@ -20,6 +25,7 @@ vi.mock("../src/core/server.js", () => ({ mocks.serverConstructor(rootPath); } run = mocks.serverRun; + stop = mocks.serverStop; }, })); vi.mock("../src/watcher/index.js", () => ({ @@ -29,35 +35,40 @@ vi.mock("../src/watcher/index.js", () => ({ } on = mocks.watcherOn; start = mocks.watcherStart; + stop = mocks.watcherStop; }, })); vi.mock("../src/core/logger.js", () => ({ RuntimeLogger: { info: mocks.loggerInfo } })); +vi.mock("../src/core/blackboard.js", () => ({ AgenticBlackboard: { flushPendingWrites: mocks.flush } })); +vi.mock("../src/history/event-store.js", () => ({ + EventStore: { getInstance: () => ({ close: mocks.eventStoreClose }) }, +})); describe("runtime bootstrap", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.serverRun.mockResolvedValue(undefined); + mocks.serverStop.mockResolvedValue(undefined); + mocks.watcherStop.mockResolvedValue(undefined); + mocks.dashboardStop.mockResolvedValue(undefined); + mocks.flush.mockResolvedValue(undefined); delete process.env.PORT; + delete process.env.PROMPT_REFINER_BACKGROUND; }); - it("starts dashboard, watcher, and MCP server and forwards file events", async () => { + it("starts a lightweight MCP server without competing background services", async () => { await import("../src/index.js"); - expect(mocks.dashboardStart).toHaveBeenCalledOnce(); - expect(mocks.dashboardStart).toHaveBeenCalledWith(3000, process.cwd()); + expect(mocks.dashboardStart).not.toHaveBeenCalled(); expect(mocks.serverConstructor).toHaveBeenCalledWith(process.cwd()); - expect(mocks.watcherConstructor).toHaveBeenCalledWith(process.cwd()); - expect(mocks.watcherStart).toHaveBeenCalledOnce(); - expect(mocks.serverRun).toHaveBeenCalledOnce(); - const changeHandler = mocks.watcherOn.mock.calls.find(call => call[0] === "change")?.[1]; - changeHandler({ event: "change", path: `${process.cwd()}\\src\\a.ts` }); - expect(mocks.loggerInfo).toHaveBeenCalledWith(expect.stringContaining("[FS] change")); - expect(mocks.dashboardLog).toHaveBeenCalledWith(expect.stringContaining("[FS] change")); + expect(mocks.watcherConstructor).not.toHaveBeenCalled(); + expect(mocks.serverRun).toHaveBeenCalledWith({ background: false }); }); - it("uses the configured dashboard port and exits on fatal server failure", async () => { + it("starts background ownership explicitly and exits on fatal server failure", async () => { process.env.PORT = "4321"; + process.env.PROMPT_REFINER_BACKGROUND = "true"; const error = new Error("startup failed"); mocks.serverRun.mockRejectedValue(error); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -67,6 +78,8 @@ describe("runtime bootstrap", () => { await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); expect(mocks.dashboardStart).toHaveBeenCalledWith(4321, process.cwd()); + expect(mocks.watcherStart).toHaveBeenCalledOnce(); expect(consoleError).toHaveBeenCalledWith("[FATAL ERROR]", error); + expect(mocks.serverStop).toHaveBeenCalledOnce(); }); }); diff --git a/universal-refiner/tests/logger.test.ts b/universal-refiner/tests/logger.test.ts index 7dc96ed..1863052 100644 --- a/universal-refiner/tests/logger.test.ts +++ b/universal-refiner/tests/logger.test.ts @@ -3,6 +3,55 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { RuntimeLogger } from "../src/core/logger.js"; +import { REDACTED, containsSensitiveContent, isSensitiveFilename, redact, redactString } from "../src/core/redaction.js"; + +describe("redaction", () => { + it("redacts free-form assignments, authorization values, and URL secrets", () => { + const value = redactString("password=hunter2 Bearer abc.def https://user:pass@example.com/a?token=abc&safe=yes"); + + expect(value).not.toContain("hunter2"); + expect(value).not.toContain("abc.def"); + expect(value).not.toContain("user"); + expect(value).not.toContain(":pass@"); + expect(value).not.toContain("token=abc"); + expect(value).toContain("safe=yes"); + expect(redactString("OPENAI_API_KEY=provider-secret")).toBe(`OPENAI_API_KEY=${REDACTED}`); + expect(redactString("http://%")).toBe("http://%"); + }); + + it("recursively redacts secret keys, errors, cycles, arrays, and hostile objects", () => { + const circular: { token: string; self?: unknown } = { token: "hidden" }; + circular.self = circular; + const hostile = new Proxy({}, { ownKeys: () => { throw new Error("hidden"); } }); + const error = new Error("password=hidden"); + error.stack = ""; + + expect(redact({ apiKey: "hidden", "x-api-key": "hidden", aws_secret_access_key: "hidden", nested: ["safe", circular], error, hostile, count: 1, empty: null })).toEqual({ + apiKey: REDACTED, + "x-api-key": REDACTED, + aws_secret_access_key: REDACTED, + nested: ["safe", { token: REDACTED, self: "[Circular]" }], + error: { name: "Error", message: `password=${REDACTED}`, stack: "" }, + hostile: REDACTED, + count: 1, + empty: null, + }); + }); + + it("identifies sensitive filenames and credential-bearing content", () => { + expect(isSensitiveFilename("config/credentials.ts")).toBe(true); + expect(isSensitiveFilename("src/service.ts")).toBe(false); + expect(isSensitiveFilename("")).toBe(false); + expect(containsSensitiveContent("const key = process.env.API_KEY")).toBe(false); + expect(containsSensitiveContent("interface Login { password: string }")).toBe(false); + expect(containsSensitiveContent("Authorization: Basic abc123")).toBe(true); + expect(containsSensitiveContent("-----BEGIN PRIVATE KEY-----")).toBe(true); + expect(containsSensitiveContent('apiKey: "literal-secret"')).toBe(true); + expect(containsSensitiveContent("token=abcdefghijklmnop")).toBe(true); + expect(containsSensitiveContent('AZURE_CLIENT_SECRET="provider-secret"')).toBe(true); + expect(containsSensitiveContent("https://user:pass@example.com")).toBe(true); + }); +}); describe("RuntimeLogger", () => { let directory: string; @@ -52,6 +101,7 @@ describe("RuntimeLogger", () => { RuntimeLogger.info("none"); RuntimeLogger.info("string", "detail"); RuntimeLogger.info("object", { ready: true }); + RuntimeLogger.info("bigint", 1n); const errorWithoutStack = new Error("failure"); errorWithoutStack.stack = ""; RuntimeLogger.info("error", errorWithoutStack); @@ -64,8 +114,21 @@ describe("RuntimeLogger", () => { expect(log).toContain("[INFO] none"); expect(log).toContain("string | detail"); expect(log).toContain('object | {"ready":true}'); - expect(log).toContain("error | Error: failure"); - expect(log).toContain("circular | [object Object]"); + expect(log).toContain("bigint | 1"); + expect(log).toContain('error | {"name":"Error","message":"failure","stack":""}'); + expect(log).toContain('circular | {"self":"[Circular]"}'); + }); + + it("redacts secrets from messages, string metadata, nested metadata, and errors", () => { + RuntimeLogger.info("token=message-secret", "password=string-secret"); + RuntimeLogger.warn("nested", { auth: { access_token: "nested-secret" }, safe: "visible" }); + RuntimeLogger.error("failure", new Error("api_key=error-secret")); + + const log = fs.readFileSync(path.join(directory, "runtime.log"), "utf8"); + expect(log).not.toMatch(/message-secret|string-secret|nested-secret|error-secret/); + expect(log).toContain(REDACTED); + expect(log).toContain("visible"); + expect(consoleError).not.toHaveBeenCalledWith(expect.stringMatching(/message-secret|string-secret|nested-secret|error-secret/)); }); it("uses the home default and continues when file output fails", () => { diff --git a/universal-refiner/tests/mcp-client.test.ts b/universal-refiner/tests/mcp-client.test.ts index 6cb59a4..b007efa 100644 --- a/universal-refiner/tests/mcp-client.test.ts +++ b/universal-refiner/tests/mcp-client.test.ts @@ -28,9 +28,11 @@ import { callMcpTool, resolveServerPath } from "../hooks/lib/mcp-client.js"; describe("hook MCP client", () => { beforeEach(() => { - mocks.close.mockResolvedValue(undefined); - mocks.connect.mockResolvedValue(undefined); - mocks.existsSync.mockReturnValue(false); + mocks.close.mockReset().mockResolvedValue(undefined); + mocks.connect.mockReset().mockResolvedValue(undefined); + mocks.request.mockReset(); + mocks.transport.mockReset(); + mocks.existsSync.mockReset().mockReturnValue(false); }); afterEach(() => { @@ -47,7 +49,10 @@ describe("hook MCP client", () => { await expect(callMcpTool("lint_prompt", { prompt: "test" })).resolves.toBe("result"); expect(mocks.transport).toHaveBeenCalledWith(expect.objectContaining({ args: [resolveServerPath()] })); - expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 25 }); + const requestOptions = mocks.request.mock.calls[0][2] as { timeout: number; maxTotalTimeout: number }; + expect(requestOptions.timeout).toBeGreaterThan(0); + expect(requestOptions.timeout).toBeLessThanOrEqual(25); + expect(requestOptions.maxTotalTimeout).toBe(requestOptions.timeout); expect(mocks.close).toHaveBeenCalledOnce(); }); @@ -59,12 +64,72 @@ describe("hook MCP client", () => { expect(mocks.close).toHaveBeenCalledTimes(2); }); + it("retries one reconnect-safe transport failure with a fresh client", async () => { + mocks.request + .mockRejectedValueOnce(Object.assign(new Error("private transport detail"), { code: "ECONNRESET" })) + .mockResolvedValueOnce({ content: [{ type: "text", text: "recovered" }] }); + + await expect(callMcpTool("lint_prompt", {})).resolves.toBe("recovered"); + expect(mocks.connect).toHaveBeenCalledTimes(2); + expect(mocks.request).toHaveBeenCalledTimes(2); + expect(mocks.close).toHaveBeenCalledTimes(2); + }); + + it("does not retry non-transport failures or more than once", async () => { + mocks.request + .mockRejectedValueOnce(Object.assign(new Error("closed"), { code: -32000 })) + .mockRejectedValueOnce(Object.assign(new Error("closed again"), { code: -32000 })); + + await expect(callMcpTool("lint_prompt", {})).rejects.toThrow("closed again"); + expect(mocks.request).toHaveBeenCalledTimes(2); + + mocks.request.mockReset().mockRejectedValueOnce(new Error("tool failed")); + await expect(callMcpTool("lint_prompt", {})).rejects.toThrow("tool failed"); + expect(mocks.request).toHaveBeenCalledOnce(); + + mocks.request.mockReset().mockRejectedValueOnce("non-error failure"); + await expect(callMcpTool("lint_prompt", {})).rejects.toBe("non-error failure"); + }); + + it("bounds the total connect and request duration", async () => { + vi.useFakeTimers(); + process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "25"; + mocks.connect.mockImplementation(() => new Promise(() => undefined)); + + const result = callMcpTool("lint_prompt", {}); + const assertion = expect(result).rejects.toMatchObject({ code: -32001 }); + await vi.advanceTimersByTimeAsync(25); + + await assertion; + expect(mocks.request).not.toHaveBeenCalled(); + expect(mocks.close).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); + + it("shares one deadline across connection and request", async () => { + vi.useFakeTimers(); + process.env.PROMPTIMPROVER_HOOK_TIMEOUT_MS = "25"; + mocks.connect.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 15))); + mocks.request.mockImplementation(() => new Promise(() => undefined)); + + const result = callMcpTool("lint_prompt", {}); + const assertion = expect(result).rejects.toMatchObject({ code: -32001 }); + await vi.advanceTimersByTimeAsync(15); + expect(mocks.request).toHaveBeenCalledOnce(); + expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 10, maxTotalTimeout: 10 }); + await vi.advanceTimersByTimeAsync(10); + + await assertion; + expect(mocks.close).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); + it("resolves built server candidates and uses the default timeout", async () => { mocks.request.mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); expect(resolveServerPath()).toMatch(/src[\\/]index\.js$/); await callMcpTool("lint_prompt", {}); - expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 15_000 }); + expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 15_000, maxTotalTimeout: 15_000 }); }); it("selects an existing built candidate, rejects invalid timeouts, and tolerates close failures", async () => { @@ -75,6 +140,6 @@ describe("hook MCP client", () => { expect(resolveServerPath()).toMatch(/dist[\\/]src[\\/]index\.js$/); await expect(callMcpTool("lint_prompt", {})).resolves.toBe("ok"); - expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 15_000 }); + expect(mocks.request.mock.calls[0][2]).toEqual({ timeout: 15_000, maxTotalTimeout: 15_000 }); }); }); diff --git a/universal-refiner/tests/register-global.test.ts b/universal-refiner/tests/register-global.test.ts new file mode 100644 index 0000000..6bd172e --- /dev/null +++ b/universal-refiner/tests/register-global.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const script = join(repoRoot, "register-global.ps1"); +const roots: string[] = []; + +function makeRoot(): string { + const root = join(tmpdir(), `promptimprover-register-${process.pid}-${Date.now()}-${roots.length}`, "KimHarjamäki"); + mkdirSync(root, { recursive: true }); + roots.push(resolve(root, "..")); + return root; +} + +function run(root: string, mode: "-Check" | "-Apply") { + return spawnSync("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", script, + mode, + "-ProfileRoot", root, + "-ObsidianVaultPath", join(root, "Obsidian Vault"), + ], { encoding: "utf8" }); +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("global registration doctor", () => { + it("applies idempotent Unicode-safe merges while preserving unrelated config", () => { + const root = makeRoot(); + mkdirSync(join(root, ".claude"), { recursive: true }); + mkdirSync(join(root, ".gemini"), { recursive: true }); + mkdirSync(join(root, ".codex"), { recursive: true }); + writeFileSync(join(root, ".claude.json"), JSON.stringify({ unrelated: { enabled: true } }), "utf8"); + writeFileSync(join(root, ".claude", "settings.json"), JSON.stringify({ permissions: { allow: ["Read"] } }), "utf8"); + writeFileSync(join(root, ".gemini", "settings.json"), JSON.stringify({ + theme: "dark", + hooks: { + BeforeAgent: [ + { hooks: [{ name: "unrelated", type: "command", command: "keep-hook" }] }, + { hooks: [{ name: "stale", type: "command", command: "promptimprover-hook-pre", timeout: 1 }] }, + ], + }, + }), "utf8"); + writeFileSync(join(root, ".codex", "config.toml"), 'model = "test"\n\n[mcp_servers.keep]\ncommand = "keep"\n', "utf8"); + + const first = run(root, "-Apply"); + expect(first.status, first.stderr || first.stdout).toBe(0); + + const claude = JSON.parse(readFileSync(join(root, ".claude.json"), "utf8")); + const gemini = JSON.parse(readFileSync(join(root, ".gemini", "settings.json"), "utf8")); + const codex = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + expect(claude.unrelated.enabled).toBe(true); + expect(claude.mcpServers["prompt-refiner"].args[0]).toContain("/universal-refiner/dist/src/index.js"); + expect(claude.mcpServers.obsidian.args.at(-1)).toContain("KimHarjamäki/Obsidian Vault"); + expect(gemini.theme).toBe("dark"); + expect(gemini.hooks.BeforeAgent).toHaveLength(2); + expect(gemini.hooks.BeforeAgent).toContainEqual({ hooks: [{ name: "unrelated", type: "command", command: "keep-hook" }] }); + expect(gemini.hooks.BeforeAgent).toContainEqual({ + hooks: [{ name: "promptimprover-pre-prompt", type: "command", command: "promptimprover-hook-pre", timeout: 20 }], + }); + expect(codex).toContain('[mcp_servers.keep]'); + expect(codex).toContain('[mcp_servers.prompt-refiner]'); + expect(codex).toContain('[mcp_servers.obsidian]'); + expect(readFileSync(join(root, ".claude.json")).subarray(0, 3).toString("hex")).not.toBe("efbbbf"); + + const backupCount = readdirSync(root, { recursive: true }).filter((name) => name.includes("promptimprover-backup")).length; + expect(backupCount).toBeGreaterThan(0); + const check = run(root, "-Check"); + expect(check.status, check.stderr || check.stdout).toBe(0); + const second = run(root, "-Apply"); + expect(second.status, second.stderr || second.stdout).toBe(0); + expect(readdirSync(root, { recursive: true }).filter((name) => name.includes("promptimprover-backup"))).toHaveLength(backupCount); + }); + + it("reports drift, mojibake, and credential field paths without printing values", () => { + const root = makeRoot(); + mkdirSync(join(root, ".gemini"), { recursive: true }); + writeFileSync(join(root, ".gemini", "settings.json"), JSON.stringify({ + title: "broken ä", + apiKey: "do-not-print-this", + }), "utf8"); + + const result = run(root, "-Check"); + expect(result.status, result.stdout + result.stderr).toBe(2); + expect(result.stdout + result.stderr).toContain("mojibake detected"); + expect(result.stdout + result.stderr).toContain("$.apiKey"); + expect(result.stdout + result.stderr).not.toContain("do-not-print-this"); + expect(existsSync(join(root, ".claude.json"))).toBe(false); + }); +}); diff --git a/universal-refiner/tests/server.test.ts b/universal-refiner/tests/server.test.ts index 940bf66..bbc930d 100644 --- a/universal-refiner/tests/server.test.ts +++ b/universal-refiner/tests/server.test.ts @@ -9,6 +9,9 @@ const lifecycle = vi.hoisted(() => ({ connect: vi.fn(), createMessage: vi.fn(), backgroundStart: vi.fn(), + backgroundStop: vi.fn(), + backgroundIdle: vi.fn(), + close: vi.fn(), })); // Mock MCP SDK @@ -18,6 +21,7 @@ vi.mock("@modelcontextprotocol/sdk/server/index.js", () => { setRequestHandler = vi.fn(); connect = lifecycle.connect; createMessage = lifecycle.createMessage; + close = lifecycle.close; } }; }); @@ -30,6 +34,8 @@ vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => { vi.mock("../src/core/background-service.js", () => ({ BackgroundAutonomyService: class { start = lifecycle.backgroundStart; + stop = lifecycle.backgroundStop; + idle = lifecycle.backgroundIdle; }, })); @@ -40,6 +46,8 @@ describe("PromptRefinerServer", () => { beforeEach(() => { vi.clearAllMocks(); lifecycle.connect.mockResolvedValue(undefined); + lifecycle.close.mockResolvedValue(undefined); + lifecycle.backgroundIdle.mockResolvedValue(undefined); lifecycle.createMessage.mockResolvedValue({ content: { type: "text", text: "[]" } }); testDir = fs.mkdtempSync(path.join(os.tmpdir(), "server-test-")); process.env.PROMPT_REFINER_GLOBAL_DIR = testDir; @@ -69,10 +77,21 @@ describe("PromptRefinerServer", () => { expect(mockServerInstance.setRequestHandler).toHaveBeenCalledTimes(2); // One for ListTools, one for CallTool }); - it("connects transport and starts background autonomy", async () => { + it("connects transport and starts background autonomy only when requested", async () => { await server.run(); expect(lifecycle.connect).toHaveBeenCalledOnce(); + expect(lifecycle.backgroundStart).not.toHaveBeenCalled(); + + await server.stop(); + await server.stop(); + expect(lifecycle.close).toHaveBeenCalledOnce(); + + const background = new PromptRefinerServer("."); + await background.run({ background: true }); expect(lifecycle.backgroundStart).toHaveBeenCalledOnce(); + await background.stop(); + expect(lifecycle.backgroundStop).toHaveBeenCalledOnce(); + expect(lifecycle.backgroundIdle).toHaveBeenCalledOnce(); }); it("handles MCP sampling text, non-text, unsupported, and transient failures", async () => { diff --git a/universal-refiner/tests/snippets.test.ts b/universal-refiner/tests/snippets.test.ts index 9778a04..4862e25 100644 --- a/universal-refiner/tests/snippets.test.ts +++ b/universal-refiner/tests/snippets.test.ts @@ -53,4 +53,48 @@ def helper(): expect(snippets.length).toBeGreaterThan(0); expect(snippets.some(s => s.symbolName === "Service")).toBe(true); }); + + it("does not traverse symlinks or index sensitive filenames and content", async () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "snippets-outside-")); + fs.writeFileSync(path.join(outside, "escaped.ts"), "export function escapedSecret() {}"); + fs.symlinkSync(outside, path.join(tmpDir, "linked-outside"), "junction"); + fs.writeFileSync(path.join(tmpDir, "credentials.ts"), "export function filenameSecret() {}"); + fs.writeFileSync(path.join(tmpDir, "literal.ts"), 'const apiKey = "literal-secret"; export function contentSecret() {}'); + fs.writeFileSync(path.join(tmpDir, "safe.ts"), "interface Login { password: string }\nexport function safeSource() {}"); + + try { + await NeuralSnippets.initialize(tmpDir); + + expect(await NeuralSnippets.search("escapedSecret", tmpDir)).toEqual([]); + expect(await NeuralSnippets.search("filenameSecret", tmpDir)).toEqual([]); + expect(await NeuralSnippets.search("contentSecret", tmpDir)).toEqual([]); + expect(await NeuralSnippets.search("safeSource", tmpDir)).toMatchObject([{ symbolName: "safeSource" }]); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + }); + + it("rejects missing, outside, and swapped traversal results during canonical checks", async () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "snippets-canonical-outside-")); + const outsideFile = path.join(outside, "outside.ts"); + const linkedOutside = path.join(tmpDir, "linked-outside"); + const insideFile = path.join(tmpDir, "inside.ts"); + fs.writeFileSync(outsideFile, "export function outsideSource() {}"); + fs.writeFileSync(insideFile, "export function insideSource() {}"); + fs.symlinkSync(outside, linkedOutside, "junction"); + + const walkDir = (NeuralSnippets as any).walkDir.bind(NeuralSnippets); + expect(await walkDir(path.join(tmpDir, "missing"), tmpDir)).toEqual([]); + expect(await walkDir(tmpDir, outside)).toEqual([]); + + const originalWalkDir = (NeuralSnippets as any).walkDir; + (NeuralSnippets as any).walkDir = async () => [linkedOutside, outsideFile]; + try { + await NeuralSnippets.initialize(tmpDir); + expect(await NeuralSnippets.search("outsideSource", tmpDir)).toEqual([]); + } finally { + (NeuralSnippets as any).walkDir = originalWalkDir; + fs.rmSync(outside, { recursive: true, force: true }); + } + }); }); diff --git a/universal-refiner/tests/stress/real-process-recovery.stress.test.ts b/universal-refiner/tests/stress/real-process-recovery.stress.test.ts new file mode 100644 index 0000000..192b561 --- /dev/null +++ b/universal-refiner/tests/stress/real-process-recovery.stress.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { runAbruptRecovery } from "../../scripts/operations/event-store-abrupt-recovery.mjs"; +import { runEventStoreSoak } from "../../scripts/stress/event-store-soak.mjs"; + +describe("real-process EventStore recovery and soak", () => { + it("recovers every committed event after abrupt process termination", async () => { + const result = await runAbruptRecovery({ writes: 12 }); + + expect(result).toMatchObject({ integrity: "ok", recoveredWrites: 13 }); + }, 30_000); + + it("runs a finite mixed-operation soak within integrity thresholds", async () => { + const result = await runEventStoreSoak({ + workers: 2, + durationMs: 500, + minOperations: 6, + maxLossRatio: 0, + }); + + expect(result.integrity).toBe("ok"); + expect(result.lossRatio).toBe(0); + expect(result.expected.operations).toBeGreaterThanOrEqual(6); + }, 35_000); +}); diff --git a/universal-refiner/tests/write-gaps.cjs b/universal-refiner/tests/write-gaps.cjs new file mode 100644 index 0000000..39b5cfa --- /dev/null +++ b/universal-refiner/tests/write-gaps.cjs @@ -0,0 +1,32 @@ +const fs = require("fs"); +const path = require("path"); + +const outPath = path.join(__dirname, "coverage-gaps.test.ts"); +const source = `import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +describe("ConfigManager gap coverage", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cfg-gap-")); + delete process.env.PROMPT_REFINER_GLOBAL_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns an empty object when the repository config contains invalid JSON", async () => { + const { ConfigManager } = await import("../src/core/config.js"); + fs.writeFileSync(path.join(tmpDir, ".gemini-refiner.json"), "{ NOT VALID JSON }"); + + expect(ConfigManager.loadConfig(tmpDir)).toEqual({}); + }); +}); +`; + +fs.writeFileSync(outPath, source, "utf8"); +console.log(`Wrote ${outPath}`); From 27e8c112ade1cf3f3bc078148fbd6a550522ac3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Mon, 15 Jun 2026 15:31:17 +0300 Subject: [PATCH 2/3] docs: update STATE.md with GSD frontmatter Co-Authored-By: Claude Sonnet 4.6 --- .planning/STATE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.planning/STATE.md b/.planning/STATE.md index 4fafb50..797540c 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,30 +1,52 @@ +--- +gsd_state_version: 1.0 +milestone: v1.0 +milestone_name: milestone +status: unknown +last_updated: "2026-06-14T14:22:05.343Z" +last_activity: "2026-06-10 - Completed quick task 260610-pps: Fix PR #1 command documentation blocker." +progress: + total_phases: 3 + completed_phases: 0 + total_plans: 1 + completed_plans: 0 + percent: 0 +--- + # Project State: Universal Refiner - Background Autonomy ## Project Reference + **Core Value**: Continuous, background-learning autonomous system for prompt refinement. **Current Focus**: Initializing the Background Autonomy (Auto-Pilot) milestone. ## Current Position + - **Phase**: 1 - Real-time File System Watcher - **Plan**: TBD - **Status**: Starting milestone - **Progress**: [░░░░░░░░░░░░░░░░░░░░] 0% ## Performance Metrics + - **Requirement Coverage**: 100% (6/6 mapped) - **Phase Completion**: 0/3 - **Active Blockers**: None ## Accumulated Context + ### Decisions + - Initialized milestone with 3 phases covering FS watching, pipeline automation, and dashboard visibility. - Chose "Zero-touch" as a core constraint to ensure autonomy. ### Todos + - [ ] Plan Phase 1 - [ ] Research best FS watching library for TypeScript/Node.js in this environment. ### Blockers + - None. ### Quick Tasks Completed @@ -34,5 +56,6 @@ | 260610-pps | Fix PR #1 review blocker: README and build_and_install.ps1 must accurately document the globally exposed gemini-prompt-refiner command | 2026-06-10 | this commit | Verified | [260610-pps-fix-pr-1-review-blocker-readme-and-build](./quick/260610-pps-fix-pr-1-review-blocker-readme-and-build/) | ## Session Continuity + Last activity: 2026-06-10 - Completed quick task 260610-pps: Fix PR #1 command documentation blocker. The project is set up with a clear 3-phase roadmap. Next step is to begin planning Phase 1. From 60c19151b68534e8254912b83dc2ce4090cf08c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Mon, 15 Jun 2026 18:19:57 +0300 Subject: [PATCH 3/3] chore: update register-global scripts and tests Co-Authored-By: Claude Sonnet 4.6 --- .../scripts/operations/REGISTER-GLOBAL.md | 2 +- .../scripts/operations/register-global.ps1 | 50 +++++++++++++++---- .../tests/register-global.test.ts | 13 +++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/universal-refiner/scripts/operations/REGISTER-GLOBAL.md b/universal-refiner/scripts/operations/REGISTER-GLOBAL.md index 3bbcce9..5422ab4 100644 --- a/universal-refiner/scripts/operations/REGISTER-GLOBAL.md +++ b/universal-refiner/scripts/operations/REGISTER-GLOBAL.md @@ -25,7 +25,7 @@ Optional overrides: -ObsidianVaultPath 'C:\repo\global.obsidian' ``` -The script preserves unrelated JSON and TOML configuration. In `-Apply` mode it creates timestamped backups before changed files are replaced through same-directory atomic UTF-8 writes. The doctor reports mojibake and suspicious plaintext credential field paths without printing credential values. +The script preserves unrelated JSON and TOML configuration. In `-Apply` mode it first preflights every target, then creates timestamped backups before changed files are replaced through same-directory atomic UTF-8 writes. Apply is refused when invalid JSON, mojibake, or suspicious plaintext credential fields are detected. Diagnostics print field paths, never credential values. Exit codes: diff --git a/universal-refiner/scripts/operations/register-global.ps1 b/universal-refiner/scripts/operations/register-global.ps1 index b0b2775..aeb791b 100644 --- a/universal-refiner/scripts/operations/register-global.ps1 +++ b/universal-refiner/scripts/operations/register-global.ps1 @@ -267,19 +267,26 @@ function Set-TomlSection { return $Content + $section } -function Update-CodexConfig { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Servers - ) +function Inspect-TomlConfig { + param([Parameter(Mandatory = $true)][string]$Path) - $content = if (Test-Path -LiteralPath $Path) { [System.IO.File]::ReadAllText($Path) } else { "" } + if (-not (Test-Path -LiteralPath $Path)) { return } + $content = [System.IO.File]::ReadAllText($Path) if (Test-Mojibake $content) { $script:Issues.Add("mojibake detected: $Path") } foreach ($match in [regex]::Matches($content, '(?im)^\s*([A-Za-z0-9_.-]*(?:key|token|secret|password|credential|authorization|bearer)[A-Za-z0-9_.-]*)\s*=\s*(?!"\$\{)[^#\r\n]+')) { $script:Issues.Add("plaintext credential field: `$.$($match.Groups[1].Value)") } +} + +function Update-CodexConfig { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Servers + ) + + $content = if (Test-Path -LiteralPath $Path) { [System.IO.File]::ReadAllText($Path) } else { "" } $desiredContent = $content foreach ($serverName in $Servers.Keys) { @@ -339,11 +346,34 @@ Write-Host "Mode: $(if ($Apply) { 'Apply' } else { 'Check' })" Write-Host "Profile: $profile" Write-Host "Checkout: $repoRoot" +$codexConfigPath = Join-Path $codexRoot "config.toml" +$claudeMcpPath = Join-Path $profile ".claude.json" +$claudeSettingsPath = Join-Path $profile ".claude\settings.json" +$geminiSettingsPath = Join-Path $profile ".gemini\settings.json" + +try { + Inspect-TomlConfig $codexConfigPath + [void](Read-JsonConfig $claudeMcpPath) + [void](Read-JsonConfig $claudeSettingsPath) + [void](Read-JsonConfig $geminiSettingsPath) +} catch { + Write-Warning $_.Exception.Message + exit 2 +} + +if ($Apply -and $script:Issues.Count -gt 0) { + foreach ($issue in $script:Issues | Select-Object -Unique) { + Write-Warning $issue + } + Write-Warning "Apply refused because preflight diagnostics must be resolved first." + exit 2 +} + try { - Update-CodexConfig (Join-Path $codexRoot "config.toml") $servers - Update-JsonConfig "Claude Code MCP" (Join-Path $profile ".claude.json") $servers - Update-JsonConfig "Claude Code hooks" (Join-Path $profile ".claude\settings.json") ([ordered]@{}) $claudeHooks["hooks"] - Update-JsonConfig "Gemini" (Join-Path $profile ".gemini\settings.json") $servers $geminiHooks["hooks"] + Update-CodexConfig $codexConfigPath $servers + Update-JsonConfig "Claude Code MCP" $claudeMcpPath $servers + Update-JsonConfig "Claude Code hooks" $claudeSettingsPath ([ordered]@{}) $claudeHooks["hooks"] + Update-JsonConfig "Gemini" $geminiSettingsPath $servers $geminiHooks["hooks"] } catch { Write-Warning $_.Exception.Message exit 2 diff --git a/universal-refiner/tests/register-global.test.ts b/universal-refiner/tests/register-global.test.ts index 6bd172e..631d818 100644 --- a/universal-refiner/tests/register-global.test.ts +++ b/universal-refiner/tests/register-global.test.ts @@ -93,4 +93,17 @@ describe("global registration doctor", () => { expect(result.stdout + result.stderr).not.toContain("do-not-print-this"); expect(existsSync(join(root, ".claude.json"))).toBe(false); }); + + it("refuses to merge invalid JSON without overwriting it", () => { + const root = makeRoot(); + mkdirSync(join(root, ".claude"), { recursive: true }); + const configPath = join(root, ".claude", "settings.json"); + writeFileSync(configPath, "{", "utf8"); + + const result = run(root, "-Apply"); + expect(result.status, result.stdout + result.stderr).toBe(2); + expect(result.stdout + result.stderr).toContain("Cannot safely merge invalid JSON config"); + expect(readFileSync(configPath, "utf8")).toBe("{"); + expect(existsSync(join(root, ".codex", "config.toml"))).toBe(false); + }); });