feat: initial commit for omp-hooks plugin

This commit is contained in:
Agent committed 2026-10-13 07:54:44 +00:00
commit a0d4230537
6 files changed
+624

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
# @local/omp-hooks
Project-level and session-level lifecycle hooks for DeepSeek Harness (DSH), with full compatibility for Claude Code `.claude/settings.json`, `.claude/hooks.json`, and project-root `hooks.json`.
## Supported Hook Events
| Event | Firing Moment | Capabilities |
|---|---|---|
| `SessionStart` | On agent creation / session init | Injects context before the first turn |
| `UserPromptSubmit` | When user prompt / messages are submitted | Can block prompt (exit 2) or attach extra context |
| `PreToolUse` | Before any tool executes | Can block execution (`deny`), ask confirmation (`ask`), or pass |
| `PostToolUse` | After a tool finishes execution | Can block result with feedback, or append context |
| `Stop` | When agent turn is stopping | Can steer agent into continuing with a reason |
## Configuration Locations (per-project discovery)
In any project workspace directory, hooks are automatically discovered from:
1. `.claude/settings.local.json`
2. `.claude/settings.json`
3. `.claude/hooks.json`
4. `hooks.json`
## Example `.claude/settings.json`
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "bash|write",
"hooks": [
{
"type": "command",
"command": "python3 $CLAUDE_PROJECT_DIR/.claude/hooks/guard.py",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "edit|write",
"hooks": [
{
"type": "command",
"command": "npx prettier --write $CLAUDE_PROJECT_DIR/src"
}
]
}
]
}
}
```
+5
View File
@@ -0,0 +1,5 @@
- insert:
- id: omp-hooks
name: '@local/omp-hooks'
config:
enabled: true
+423
View File
@@ -0,0 +1,423 @@
/**
* @local/omp-hooks — Project-level & session-level lifecycle hooks for DSH.
* Full compatibility with Claude Code .claude/settings.json, .claude/hooks.json,
* and project-level hooks.
*
* Interception seams:
* - SessionStart -> agent/created (injects initial prompt context)
* - UserPromptSubmit -> agent/pre-step (can block prompt or attach context)
* - PreToolUse -> tools/pre-execute (can deny or ask approval before execution)
* - PostToolUse -> tools/post-execute (can block result or attach context)
* - Stop -> agent/turn-stopping (can steer agent to force continuation)
*/
import fs from 'node:fs'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { createUserMessage } from './lib/message.js'
export const name = 'omp-hooks'
export const inject = ['tools']
const CLAUDE_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'Stop',
'SubagentStart',
'SubagentStop',
]
const CONTEXT_SOURCE = { kind: 'omp-hooks' }
function asObject(val) {
return typeof val === 'object' && val !== null && !Array.isArray(val) ? val : undefined
}
export function substituteCommand(command, vars = {}) {
let out = command
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
return out
}
export function parseClaudeConfig(raw, vars = {}) {
const config = {}
const root = asObject(raw)
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (!hooksMap) return config
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups = []
for (const rawGroup of rawGroups) {
const group = asObject(rawGroup)
if (!group || !Array.isArray(group.hooks)) continue
const commands = []
for (const rawHook of group.hooks) {
const hook = asObject(rawHook)
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') continue
if (typeof hook.command !== 'string') continue
commands.push({
command: substituteCommand(hook.command, vars),
timeoutSec: typeof hook.timeout === 'number' ? hook.timeout : 600,
})
}
if (commands.length === 0) continue
const matcher = (event === 'UserPromptSubmit' || event === 'Stop')
? undefined
: (typeof group.matcher === 'string' ? group.matcher : undefined)
groups.push({
matcher,
hooks: commands,
})
}
if (groups.length > 0) config[event] = groups
}
return config
}
/**
* Discovers hook configuration in order of precedence:
* 1. .claude/settings.local.json
* 2. .claude/settings.json
* 3. .claude/hooks.json
* 4. hooks.json
*/
export function findProjectHookConfig(projectDir) {
if (!projectDir || typeof projectDir !== 'string') return null
const candidates = [
path.join(projectDir, '.claude', 'settings.local.json'),
path.join(projectDir, '.claude', 'settings.json'),
path.join(projectDir, '.claude', 'hooks.json'),
path.join(projectDir, 'hooks.json'),
]
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) {
const text = fs.readFileSync(candidate, 'utf8')
const raw = JSON.parse(text)
return parseClaudeConfig(raw, { projectDir })
}
} catch {}
}
return null
}
export function matchesPattern(pattern, subject) {
if (!pattern || pattern === '*' || pattern === '') return true
if (pattern === subject) return true
try {
const rx = new RegExp(`^(${pattern})$`)
return rx.test(subject)
} catch {
return false
}
}
/**
* Execute a hook command with JSON payload on stdin
*/
export async function executeHookCommand(cmdStr, payload, { cwd, timeoutSec = 600, signal }) {
return new Promise((resolve) => {
let resolved = false
const done = (res) => {
if (resolved) return
resolved = true
resolve(res)
}
const timer = setTimeout(() => {
done({ exitCode: 124, stdout: '', stderr: 'Hook timed out' })
try { proc.kill('SIGTERM') } catch {}
}, timeoutSec * 1000)
const env = {
...process.env,
CLAUDE_PROJECT_DIR: cwd || process.cwd(),
}
const proc = spawn('bash', ['-c', cmdStr], {
cwd: cwd || process.cwd(),
env,
stdio: ['pipe', 'pipe', 'pipe'],
})
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer)
try { proc.kill('SIGKILL') } catch {}
done({ exitCode: 130, stdout: '', stderr: 'Operation cancelled' })
}, { once: true })
}
let stdout = ''
let stderr = ''
proc.stdout.on('data', (d) => { stdout += d.toString('utf8') })
proc.stderr.on('data', (d) => { stderr += d.toString('utf8') })
proc.on('error', (err) => {
clearTimeout(timer)
done({ exitCode: 1, stdout: '', stderr: String(err?.message ?? err) })
})
proc.on('close', (code) => {
clearTimeout(timer)
done({ exitCode: code ?? 0, stdout, stderr })
})
try {
proc.stdin.write(JSON.stringify(payload) + '\n')
proc.stdin.end()
} catch {}
})
}
export function parseHookStdout(stdout, exitCode, stderr) {
let decision = 'allow' // allow | deny | ask
let reason = undefined
let additionalContext = []
// Check exit code conventions:
// Exit 2 = Deny / Block
// Exit 0 = Allow
if (exitCode === 2) {
decision = 'deny'
reason = stderr.trim() || 'Blocked by lifecycle hook (exit code 2)'
}
// Parse stdout JSON if available
const trimmed = stdout.trim()
if (trimmed) {
try {
const data = JSON.parse(trimmed)
if (data && typeof data === 'object') {
if (data.decision === 'deny' || data.blocked === true) {
decision = 'deny'
reason = data.reason || reason || 'Blocked by lifecycle hook'
} else if (data.decision === 'ask') {
decision = 'ask'
reason = data.reason
}
if (typeof data.reason === 'string' && decision === 'deny') {
reason = data.reason
}
if (typeof data.context === 'string') {
additionalContext.push(data.context)
} else if (Array.isArray(data.context)) {
additionalContext.push(...data.context.filter((c) => typeof c === 'string'))
}
if (typeof data.additionalContext === 'string') {
additionalContext.push(data.additionalContext)
} else if (Array.isArray(data.additionalContext)) {
additionalContext.push(...data.additionalContext.filter((c) => typeof c === 'string'))
}
}
} catch {
// stdout was non-JSON text; if exitCode is 0 and it's non-empty, could be context
if (exitCode === 0 && trimmed.length > 0 && !trimmed.startsWith('{')) {
additionalContext.push(trimmed)
}
}
}
return { decision, reason, additionalContext }
}
export function apply(ctx, config = {}) {
const cfg = {
enabled: true,
...config,
}
if (!cfg.enabled) return
// Cache configs per cwd with 5s expiry
const configCache = new Map()
function getConfigForDir(dir) {
const cwd = dir || process.cwd()
const now = Date.now()
const hit = configCache.get(cwd)
if (hit && now - hit.time < 5000) return hit.config
const conf = findProjectHookConfig(cwd)
configCache.set(cwd, { time: now, config: conf })
return conf
}
function cwdOf(entity) {
return entity?.session?.header?.cwd || entity?.cwd || process.cwd()
}
async function runHooks(point, matchQuery, payload, cwd, signal) {
const conf = getConfigForDir(cwd)
if (!conf) return { decision: 'allow', additionalContext: [] }
const groups = conf[point] ?? []
let finalDecision = 'allow'
let finalReason = undefined
const allContext = []
for (const group of groups) {
if (!matchesPattern(group.matcher, matchQuery)) continue
for (const hook of group.hooks) {
const res = await executeHookCommand(hook.command, payload, {
cwd,
timeoutSec: hook.timeoutSec,
signal,
})
const parsed = parseHookStdout(res.stdout, res.exitCode, res.stderr)
if (parsed.decision === 'deny') {
finalDecision = 'deny'
finalReason = parsed.reason || res.stderr.trim() || 'Blocked by hook'
} else if (parsed.decision === 'ask' && finalDecision !== 'deny') {
finalDecision = 'ask'
finalReason = parsed.reason
}
if (parsed.additionalContext.length > 0) {
allContext.push(...parsed.additionalContext)
}
}
}
return { decision: finalDecision, reason: finalReason, additionalContext: allContext }
}
// 1. SessionStart
ctx.on('agent/created', async ({ agent, source, signal }) => {
const cwd = cwdOf(agent)
const payload = {
session_id: agent?.id,
source: source || 'user',
cwd,
}
const { additionalContext } = await runHooks('SessionStart', source || '', payload, cwd, signal)
if (additionalContext.length > 0 && agent?.inject) {
agent.inject(createUserMessage({
content: additionalContext.map((text) => ({ type: 'text', text })),
source: CONTEXT_SOURCE,
}))
}
})
// 2. UserPromptSubmit
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
if (!messages || messages.length === 0) return next()
const cwd = cwdOf(agent)
const promptText = messages.map((m) => {
if (typeof m.content === 'string') return m.content
if (Array.isArray(m.content)) {
return m.content.map((c) => c.text || '').join('\n')
}
return ''
}).join('\n')
const payload = {
session_id: agent?.id,
prompt: promptText,
}
const { decision, reason, additionalContext } = await runHooks('UserPromptSubmit', '', payload, cwd, signal)
if (decision === 'deny') {
return { kind: 'deny', reason: reason || 'Prompt rejected by UserPromptSubmit hook' }
}
const downstream = await next()
if (additionalContext.length > 0 && downstream?.kind === 'enter') {
const extraMsg = createUserMessage({
content: additionalContext.map((text) => ({ type: 'text', text })),
source: CONTEXT_SOURCE,
})
return {
...downstream,
messages: [...(downstream.messages || []), extraMsg],
}
}
return downstream
})
// 3. PreToolUse
ctx.on('tools/pre-execute', async (exec, next) => {
const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd()
const payload = {
session_id: exec.agent?.id,
tool_name: exec.name,
tool_input: exec.arguments ?? {},
}
const { decision, reason } = await runHooks('PreToolUse', exec.name, payload, cwd, exec.signal)
if (decision === 'deny') {
return {
kind: 'deny',
reason: reason || `Blocked by PreToolUse hook for ${exec.name}`,
}
}
if (decision === 'ask') {
return {
kind: 'ask',
reason: reason || `Approval requested by PreToolUse hook for ${exec.name}`,
}
}
return next()
})
// 4. PostToolUse
ctx.on('tools/post-execute', async (exec, result, next) => {
const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd()
const payload = {
session_id: exec.agent?.id,
tool_name: exec.name,
tool_input: exec.arguments ?? {},
tool_result: result?.content ?? result ?? null,
is_error: !!result?.isError,
}
const { decision, reason, additionalContext } = await runHooks('PostToolUse', exec.name, payload, cwd, exec.signal)
if (decision === 'deny') {
return {
kind: 'block',
feedback: [{ type: 'text', text: reason || `Blocked by PostToolUse hook for ${exec.name}` }],
...(additionalContext.length > 0 ? {
additionalContexts: [createUserMessage({
content: additionalContext.map((t) => ({ type: 'text', text: t })),
source: CONTEXT_SOURCE,
})],
} : {}),
}
}
const downstream = await next()
if (additionalContext.length > 0) {
const extra = createUserMessage({
content: additionalContext.map((t) => ({ type: 'text', text: t })),
source: CONTEXT_SOURCE,
})
return {
...downstream,
additionalContexts: [extra, ...(downstream?.additionalContexts || [])],
}
}
return downstream
})
// 5. Stop
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => {
const cwd = cwdOf(agent)
const payload = {
session_id: agent?.id,
turn,
}
const { decision, reason } = await runHooks('Stop', '', payload, cwd, signal)
if (decision === 'deny') {
const text = reason || 'Continue: blocked by Stop hook'
agent?.steer?.(createUserMessage({
content: [{ type: 'text', text }],
source: CONTEXT_SOURCE,
}))
}
})
}
+10
View File
@@ -0,0 +1,10 @@
import crypto from 'node:crypto'
export function createUserMessage(input) {
return {
id: crypto.randomUUID(),
role: 'user',
content: input.content,
source: input.source,
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@local/omp-hooks",
"version": "1.0.0",
"description": "Project-level and session-level lifecycle hooks for DSH with full .claude/settings.json and hooks.json compatibility",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./index.js",
"./package.json": "./package.json"
},
"files": [
"index.js",
"lib/*.js",
"cordis.patch.yml"
],
"scripts": {
"test": "node --test test/*.test.mjs"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import {
parseClaudeConfig,
matchesPattern,
substituteCommand,
parseHookStdout,
executeHookCommand,
findProjectHookConfig,
} from '../index.js'
test('substituteCommand replaces CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT', () => {
const cmd = 'python3 ${CLAUDE_PROJECT_DIR}/check.py --root ${CLAUDE_PLUGIN_ROOT}'
const res = substituteCommand(cmd, {
projectDir: '/my/proj',
pluginRoot: '/my/plugin',
})
assert.equal(res, 'python3 /my/proj/check.py --root /my/plugin')
})
test('parseClaudeConfig handles settings.json format with matchers and timeouts', () => {
const raw = {
hooks: {
PreToolUse: [
{
matcher: 'bash|write',
hooks: [
{ type: 'command', command: 'echo "check"', timeout: 5 },
],
},
],
UserPromptSubmit: [
{
hooks: [
{ type: 'command', command: 'echo "prompt"' },
],
},
],
},
}
const parsed = parseClaudeConfig(raw, { projectDir: '/app' })
assert.ok(parsed.PreToolUse)
assert.equal(parsed.PreToolUse[0].matcher, 'bash|write')
assert.equal(parsed.PreToolUse[0].hooks[0].timeoutSec, 5)
assert.ok(parsed.UserPromptSubmit)
})
test('matchesPattern tests exact, regex, wildcard, and empty', () => {
assert.equal(matchesPattern('*', 'bash'), true)
assert.equal(matchesPattern('', 'bash'), true)
assert.equal(matchesPattern(undefined, 'bash'), true)
assert.equal(matchesPattern('bash', 'bash'), true)
assert.equal(matchesPattern('bash', 'read'), false)
assert.equal(matchesPattern('Edit|Write', 'Edit'), true)
assert.equal(matchesPattern('Edit|Write', 'Write'), true)
assert.equal(matchesPattern('Edit|Write', 'bash'), false)
})
test('parseHookStdout handles exit code 2 as deny', () => {
const res = parseHookStdout('', 2, 'Sensitive file blocked')
assert.equal(res.decision, 'deny')
assert.equal(res.reason, 'Sensitive file blocked')
})
test('parseHookStdout handles JSON decision and context', () => {
const jsonOut = JSON.stringify({
decision: 'deny',
reason: 'Security violation',
additionalContext: 'Remember security rule 101',
})
const res = parseHookStdout(jsonOut, 0, '')
assert.equal(res.decision, 'deny')
assert.equal(res.reason, 'Security violation')
assert.deepEqual(res.additionalContext, ['Remember security rule 101'])
})
test('executeHookCommand runs shell command and passes stdin payload', async () => {
const res = await executeHookCommand(
'read line; echo "Received: $line"',
{ tool: 'test_tool' },
{ timeoutSec: 5 }
)
assert.equal(res.exitCode, 0)
assert.ok(res.stdout.includes('Received: {"tool":"test_tool"}'))
})
test('findProjectHookConfig discovers .claude/settings.json', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'))
const claudeDir = path.join(tmp, '.claude')
fs.mkdirSync(claudeDir)
fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: 'bash', hooks: [{ type: 'command', command: 'echo 1' }] },
],
},
}))
const conf = findProjectHookConfig(tmp)
assert.ok(conf)
assert.ok(conf.PreToolUse)
fs.rmSync(tmp, { recursive: true, force: true })
})