Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cdb1d07
feat(panic): implement Panic Response Layer
laurentftech May 19, 2026
decc8fc
perf(search): cache LanceDB connection and BM25 rows per MCP session
laurentftech May 19, 2026
bf81a65
feat(panic): complete Gryph integration and panic signal layer
laurentftech May 20, 2026
b743b5d
refactor(panic): address pre-merge review feedback
laurentftech May 20, 2026
95f2ecb
feat(panic): refractory period + provenance trace
laurentftech May 20, 2026
76119eb
feat(panic): locality confidence modulation of panic and stale escala…
laurentftech May 20, 2026
2aa5b5b
test(telemetry): unit tests for panic/lease metric aggregation
laurentftech May 20, 2026
67e8a84
test(panic): signal detection, refractory, locality, burst escalation…
laurentftech May 21, 2026
80a8abb
feat(panic): opt-in panic response with graduated mode ladder
laurentftech May 21, 2026
90c422e
fix(telemetry): remove unused toolCall helper in computeRecovery desc…
laurentftech May 21, 2026
6eba94a
feat(panic): add privacy mode — zero instrumentation layer
laurentftech May 21, 2026
2a127fa
refactor(panic): collapse privacy→off, simplify mode ladder
laurentftech May 21, 2026
fd1f1d5
refactor(panic): collapse privacy→off, simplify mode ladder
laurentftech May 22, 2026
7eeb612
docs(panic): clarify localityConfidence role and refractory replace s…
laurentftech May 22, 2026
c141271
docs(panic): harden contracts on localityConfidence, off semantics, b…
laurentftech May 22, 2026
27633da
refactor(panic): centralize constants in panic-constants.ts
laurentftech May 22, 2026
3207495
feat(panic): Gryph runtime observability — background polling closes …
laurentftech May 22, 2026
00780a1
feat(panic): gryph-watch daemon + CAS writes + while-loop poller
laurentftech May 23, 2026
e26a551
fix(gryph): resolve binary via common paths when PATH is restricted
laurentftech May 23, 2026
00ba626
fix(gryph): correct field casing and burst detection for real Gryph s…
laurentftech May 23, 2026
00ec819
fix(panic): drop unread panic-response.jsonl emit, fix kilo double-pr…
laurentftech May 23, 2026
e490d9e
feat(panic): decay in Gryph path, gryphWindowStart window, interventi…
laurentftech May 23, 2026
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
710 changes: 710 additions & 0 deletions openspec/changes/panic-response-layer.md

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions src/cli/commands/gryph-watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* openlore gryph-watch
*
* Standalone Gryph behavioral observer. Runs as an independent background
* process — lifetime decoupled from the MCP server session. Polls Gryph every
* interval and writes behavioral signals to panic-state.json via CAS writes.
*
* Why a separate process: MCP-path Gryph polling only starts after the first
* openlore tool call. Agents working exclusively via Bash/Edit/Read never
* trigger that path. gryph-watch closes this gap by running continuously from
* session start.
*
* Signals provided (standalone, without MCP tracker context):
* repetitiveRetryBurst — low entropy + failing commands (no stale context needed)
*
* Signals requiring MCP tracker (not available here):
* largePatchWhileStale — staleDepth unknown without EpistemicLease session
*
* Install via: openlore setup --hooks claude
* Which installs a UserPromptSubmit hook: openlore gryph-watch &
*/

import { Command } from 'commander';
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { OPENLORE_DIR } from '../../constants.js';
import { readOpenLoreConfig } from '../../core/services/config-manager.js';
import { startGryphPolling } from '../../core/services/mcp-handlers/gryph-bridge.js';

const PID_FILE = 'gryph-watch.pid';

function findProjectDirectory(startDir: string): string | null {
let dir = startDir;
for (;;) {
if (existsSync(join(dir, OPENLORE_DIR, 'config.json'))) return dir;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}

function isProcessAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch { return false; }
}

export const gryphWatchCommand = new Command('gryph-watch')
.description('Background Gryph behavioral observer (install via: openlore setup --hooks)')
.argument('[directory]', 'Project directory — auto-detected from cwd if omitted')
.action(async (directoryArg?: string) => {
const directory = directoryArg
?? findProjectDirectory(process.cwd())
?? process.cwd();

const cfg = await readOpenLoreConfig(directory);
const mode = cfg?.panicResponse?.mode ?? 'off';
if (mode === 'off') process.exit(0);

// Singleton enforcement: one watcher per directory
const pidPath = join(directory, OPENLORE_DIR, PID_FILE);
if (existsSync(pidPath)) {
try {
const existing = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10);
if (!isNaN(existing) && isProcessAlive(existing)) process.exit(0);
} catch { /* stale PID file — proceed */ }
}
try { writeFileSync(pidPath, String(process.pid), 'utf-8'); } catch { /* non-fatal */ }

const cleanup = (): void => {
try { unlinkSync(pidPath); } catch { /* ignore */ }
process.exit(0);
};
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
// Detect parent process death via stdin EOF (pipe from shell/agent closes)
process.stdin.resume();
process.stdin.on('close', cleanup);

// startGryphPolling drives a while loop internally — pending setTimeout keeps
// the process alive. getTracker: () => null is intentional: staleDepth is
// unknown without an active MCP session; largePatchWhileStale is MCP-path-only.
startGryphPolling({ directory, getTracker: () => null });
});
91 changes: 80 additions & 11 deletions src/cli/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ import {
} from '@modelcontextprotocol/sdk/types.js';

import { sanitizeMcpError, validateDirectory } from '../../core/services/mcp-handlers/utils.js';
import { createTracker, updateTracker, getFreshnessSignal } from '../../core/services/mcp-handlers/epistemic-lease.js';
import { createTracker, updateTracker, updatePanic, getFreshnessSignal, trackerToPanicState } from '../../core/services/mcp-handlers/epistemic-lease.js';
import type { EpistemicTracker } from '../../core/services/mcp-handlers/epistemic-lease.js';
import type { PanicResponseMode } from '../../types/index.js';
import { readPanicState, writePanicState, getPanicSignalText } from '../../core/services/mcp-handlers/panic-response.js';
import { emit } from '../../core/services/telemetry.js';
import { readOpenLoreConfig } from '../../core/services/config-manager.js';
import { DEFAULT_DRIFT_MAX_FILES } from '../../constants.js';
import {
handleGetCallGraph,
Expand Down Expand Up @@ -1306,6 +1309,7 @@ async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
// Per-session epistemic lease tracker — re-initialized when directory changes.
let tracker: EpistemicTracker | undefined;
let trackerDir = '';
let panicPolicy: PanicResponseMode = 'off';

// --watch-auto: start the watcher on the first tool call that carries a directory
let autoWatcher: import('../../core/services/mcp-watcher.js').McpWatcher | undefined;
Expand Down Expand Up @@ -1354,9 +1358,51 @@ async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
if (directory && (!tracker || directory !== trackerDir)) {
tracker = createTracker(directory);
trackerDir = directory;
const cfg = await readOpenLoreConfig(directory);
panicPolicy = cfg?.panicResponse?.mode ?? 'off';
}
// Update epistemic state before dispatch (orient resets tracker internally).
// Invariant: only MCP tool calls (this path) feed panic. CLI commands (panic-check,
// telemetry) are separate processes that read state but never call updateTracker —
// no recursive panic feedback loop from openlore internal commands.
if (tracker && directory) {
const prevOrientResetAt = tracker.lastOrientResetAt;
updateTracker(tracker, name, directory, typeof filePath === 'string' ? filePath : undefined);
const orientJustFired = tracker.lastOrientResetAt !== prevOrientResetAt;

if (panicPolicy !== 'off') {
// Read disk state to preserve hook-written fields (lastHookInterventionAt, gryphWindowStart)
// that panic-check (separate process) may have set since the last MCP write.
const diskState = readPanicState(directory);
updatePanic(tracker, {
density: tracker.density,
oscillation: tracker.oscillation,
weight: 1,
staleDepth: tracker.staleDepth,
directory,
tool: name,
});
const stateToWrite = {
...trackerToPanicState(tracker, agentName),
lastHookInterventionAt: diskState.lastHookInterventionAt,
gryphWindowStart: diskState.gryphWindowStart,
};
tracker.panicRevision = writePanicState(directory, stateToWrite);

// Feedback loop: did orient() respond to a prior hook intervention?
if (orientJustFired && diskState.lastHookInterventionAt) {
const lagMs = Date.now() - new Date(diskState.lastHookInterventionAt).getTime();
if (lagMs < 5 * 60 * 1000) {
emit(directory, 'panic', {
event: 'panic_intervention_outcome',
outcome: 'responded',
intervention_lag_ms: lagMs,
orient_kind: tracker.recentOrientCount >= 3 ? 'spam' : tracker.recentOrientCount >= 2 ? 'rapid' : 'normal',
});
}
}
}
}
// Update epistemic state before dispatch (orient resets tracker internally)
if (tracker && directory) updateTracker(tracker, name, directory, typeof filePath === 'string' ? filePath : undefined);

let result: unknown;

Expand Down Expand Up @@ -1540,19 +1586,42 @@ async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
};
}

emit(directory, 'mcp', { event: 'tool_call', tool: name, ms: Date.now() - _t0, agent: agentName, agent_version: agentVersion });
emit(directory, 'mcp', {
event: 'tool_call', tool: name, ms: Date.now() - _t0, agent: agentName, agent_version: agentVersion,
panic_level: tracker?.panicLevel ?? 0,
panic_score: tracker?.panicScore ?? 0,
});

const text =
typeof result === 'string' ? result : JSON.stringify(result, null, 2);
const signal = tracker ? getFreshnessSignal(tracker) : null;

// Freshness signal is a separate content item — never concatenated into
// the result body — so structured outputs (JSON, patches) are not corrupted.
const content: Array<{ type: 'text'; text: string }> = signal
? signal.prepend
? [{ type: 'text', text: signal.text }, { type: 'text', text }]
: [{ type: 'text', text }, { type: 'text', text: signal.text }]
: [{ type: 'text', text }];
// Both freshness and panic signals are separate content items — never
// concatenated into the result body — so structured outputs (JSON, patches)
// are not corrupted. Panic signal always appended (after result).
const content: Array<{ type: 'text'; text: string }> = [];
if (signal?.prepend) content.push({ type: 'text', text: signal.text });
content.push({ type: 'text', text });
if (signal && !signal.prepend) content.push({ type: 'text', text: signal.text });

if (tracker && (panicPolicy === 'advisory' || panicPolicy === 'experimental_blocking')) {
const panicState = trackerToPanicState(tracker, agentName);
const panicText = getPanicSignalText(panicState);
if (panicText) {
content.push({ type: 'text', text: panicText });
tracker.interventionCountSinceStable++;
tracker.panicRevision = writePanicState(directory, trackerToPanicState(tracker, agentName));
emit(directory, 'panic', {
event: 'panic_signal_injected',
panic_level: tracker.panicLevel,
panic_score: tracker.panicScore,
intervention_count: tracker.interventionCountSinceStable,
directive_mode: tracker.interventionCountSinceStable >= 3,
tool: name,
agent: agentName,
});
}
}

return { content };
} catch (err) {
Expand Down
108 changes: 108 additions & 0 deletions src/cli/commands/panic-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* openlore panic-check
*
* Reads panic-state.json and outputs a structured JSON decision for the
* Claude Code PreToolUse hook. Always exits 0 — severity is encoded in
* the payload, not the exit code, so the hook runtime never sees an error.
*
* Designed for minimal startup overhead: imports only node built-ins and
* constants. Heavy MCP dependencies are never loaded.
*/

import { Command } from 'commander';
import { readPanicState, writePanicState, buildPanicCheckOutput } from '../../core/services/mcp-handlers/panic-response.js';
import { queryGryphSignals, applyGryphDelta } from '../../core/services/mcp-handlers/gryph-bridge.js';
import { readOpenLoreConfig } from '../../core/services/config-manager.js';
import { emit } from '../../core/services/telemetry.js';

type HookFormat = 'claude' | 'kilo' | 'codex';

export const panicCheckCommand = new Command('panic-check')
.description('Check current panic level (PreToolUse hook consumer)')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.option('-f, --format <format>', 'Hook format: claude|kilo|codex', 'claude')
.action(async (options: { directory: string; format: string }) => {
try {
const dir = options.directory;
const format = options.format as HookFormat;

// Policy gate — config is single source of truth
const cfg = await readOpenLoreConfig(dir);
const mode = cfg?.panicResponse?.mode ?? 'off';

if (mode === 'off' || mode === 'observe') {
// Panic disabled or observe-only: hook passes through silently
process.exit(0);
}

let state = readPanicState(dir);

// Gryph enrichment — query from gryphWindowStart (2-min fallback avoids replaying hours of history)
const since = state.gryphWindowStart ?? new Date(Date.now() - 2 * 60 * 1000).toISOString();
const gryphSignals = queryGryphSignals(since);
if (gryphSignals) {
const enrichedTriggers = [...state.triggers];
const enrichedScore = applyGryphDelta(
state.panicScore,
gryphSignals,
state.panicLevel >= 2, // isStale when at L2+
enrichedTriggers,
);
if (enrichedScore !== state.panicScore) {
state = {
...state,
panicScore: enrichedScore,
triggers: enrichedTriggers,
};
}
}

const output = buildPanicCheckOutput(state);

if (output.decision === 'warn') {
const newCount = state.interventionCountSinceStable + 1;
const now = new Date().toISOString();
writePanicState(dir, {
...state,
lastHookInterventionAt: now,
gryphWindowStart: now,
interventionCountSinceStable: newCount,
});
emit(dir, 'panic', {
event: 'hook_intervention',
channel: 'pre_tool_use',
format,
panic_level: state.panicLevel,
severity: output.severity,
directive_mode: newCount >= 3,
intervention_count: newCount,
gryph_enriched: gryphSignals !== null,
});
}

// experimental_blocking: emit block signal at L4 — runtime decides enforcement.
// advisory:true is explicit in the payload: OpenLore recommends, never mandates.
// OpenLore always exits 0.
if (mode === 'experimental_blocking' && state.panicLevel >= 4) {
const blockOutput = { decision: 'block' as const, advisory: true, panicLevel: state.panicLevel, message: output.message };
process.stdout.write(JSON.stringify(blockOutput) + '\n');
process.exit(0);
}

process.stdout.write(formatOutput(output, format) + '\n');
} catch {
// fail-open: any error → silent exit 0
}
process.exit(0);
});

function formatOutput(output: ReturnType<typeof buildPanicCheckOutput>, format: HookFormat): string {
// claude and codex both consume raw JSON — codex uses the same Claude Code hook schema
if (format === 'claude' || format === 'codex') {
return JSON.stringify(output);
}

// kilo: plain-text message (some runtimes just want a string signal)
if (output.decision === 'allow') return '';
return output.message ?? `[PANIC:${output.severity?.toUpperCase() ?? 'WARN'}] Destabilization detected — call orient().`;
}
27 changes: 27 additions & 0 deletions src/cli/commands/panic-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* openlore panic-level
*
* Read-only status line output: current panic level as a compact string.
* No side effects, no writes — safe to call from a status line poller.
*
* Output: "P:L{n}" at L1–L4, empty string at L0.
* Exit: always 0.
*/

import { Command } from 'commander';
import { readPanicState } from '../../core/services/mcp-handlers/panic-response.js';

export const panicLevelCommand = new Command('panic-level')
.description('Output current panic level for status line display (read-only, exits 0)')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.action((options: { directory: string }) => {
try {
const state = readPanicState(options.directory);
if (state.panicLevel > 0) {
process.stdout.write(`P:L${state.panicLevel}`);
}
} catch {
// fail-open: output nothing
}
process.exit(0);
});
Loading
Loading