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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion universal-refiner/hooks/config/codex.config.fragment.toml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down
69 changes: 57 additions & 12 deletions universal-refiner/hooks/lib/hook-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
61 changes: 54 additions & 7 deletions universal-refiner/hooks/lib/mcp-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<string> {
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<string, unknown>, deadline: number): Promise<string> {
remainingMs(deadline);
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolveServerPath()],
Expand All @@ -16,17 +28,18 @@ export async function callMcpTool(name: string, args: Record<string, unknown>):
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);
}
}

Expand All @@ -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<T>(operation: Promise<T>, deadline: number): Promise<T> {
const remaining = remainingMs(deadline);
let timer: NodeJS.Timeout;
const timeout = new Promise<T>((_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 });
}
7 changes: 3 additions & 4 deletions universal-refiner/hooks/post-execution.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)));
}
}
Expand Down
7 changes: 3 additions & 4 deletions universal-refiner/hooks/pre-prompt.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)));
}
}
Expand Down
6 changes: 5 additions & 1 deletion universal-refiner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 12 additions & 51 deletions universal-refiner/register-global.ps1
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading