hookgate 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +10 -0
- package/CHANGELOG.md +99 -0
- package/README.md +148 -23
- package/bin/hookgate.mjs +89 -43
- package/bin/lib/config.mjs +174 -0
- package/bin/lib/doctor.mjs +58 -0
- package/bin/lib/gates.mjs +129 -0
- package/bin/lib/handlers.mjs +150 -0
- package/bin/lib/harness.mjs +64 -0
- package/bin/lib/jev.mjs +50 -0
- package/bin/lib/redact.mjs +34 -0
- package/bin/lib/report.mjs +70 -0
- package/bin/lib/store.mjs +149 -0
- package/codex/hooks.json +43 -0
- package/hooks/hooks.json +10 -14
- package/package.json +6 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// State never carries secrets. A command line can hold a token; a tool result can hold
|
|
2
|
+
// a whole .env. Redact before the request, then cap the size — Jev's state limit is
|
|
3
|
+
// 32k tokens and a hook should not be the thing that ships a log file to an API.
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '<redacted:private-key>'],
|
|
6
|
+
[/\b(sk|rk|pk)[-_](?:live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{16,}/g, '<redacted:key>'],
|
|
7
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<redacted:github-token>'],
|
|
8
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '<redacted:github-token>'],
|
|
9
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '<redacted:aws-key-id>'],
|
|
10
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, '<redacted:slack-token>'],
|
|
11
|
+
[/\bAIza[0-9A-Za-z_-]{30,}\b/g, '<redacted:google-key>'],
|
|
12
|
+
[/(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/g, '$1 <redacted>'],
|
|
13
|
+
[/\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g, '<redacted:jwt>'],
|
|
14
|
+
[/(\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*)(["']?)[^\s"'&]{6,}\2/gi, '$1$2<redacted>$2'],
|
|
15
|
+
[/(--?(?:password|passwd|token|api[-_]?key|secret)[= ])\S+/gi, '$1<redacted>'],
|
|
16
|
+
[/(https?:\/\/[^\s/@:]+:)[^\s/@]+@/g, '$1<redacted>@'],
|
|
17
|
+
[/\b[A-Fa-f0-9]{40,}\b/g, '<redacted:hex>'],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function redact(text) {
|
|
21
|
+
let out = String(text ?? '')
|
|
22
|
+
for (const [re, rep] of PATTERNS) out = out.replace(re, rep)
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncate(text, max) {
|
|
27
|
+
const s = String(text ?? '')
|
|
28
|
+
if (s.length <= max) return s
|
|
29
|
+
const head = Math.floor(max * 0.7)
|
|
30
|
+
const tail = max - head
|
|
31
|
+
return `${s.slice(0, head)}\n…[${s.length - max} chars elided by hookgate]…\n${s.slice(-tail)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const prepare = (text, max) => truncate(redact(text), max)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// `hookgate report`: the README's columns, from the audit log (HG-10), including what
|
|
2
|
+
// the decisions cost (HG-28).
|
|
3
|
+
import { readDecisions } from './store.mjs'
|
|
4
|
+
|
|
5
|
+
// TypeSafe's published input price, $42 per billion tokens; output is free.
|
|
6
|
+
// One constant, dated, shared with evals/run.mjs — a stale price is a bug.
|
|
7
|
+
export const PRICE_PER_INPUT_TOKEN = 42 / 1e9
|
|
8
|
+
export const PRICE_DATE = '2026-09-22'
|
|
9
|
+
|
|
10
|
+
const q = (xs, p) => {
|
|
11
|
+
if (!xs.length) return null
|
|
12
|
+
const s = [...xs].sort((a, b) => a - b)
|
|
13
|
+
return s[Math.min(s.length - 1, Math.floor(p * s.length))]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function summarize(records) {
|
|
17
|
+
const byGate = {}
|
|
18
|
+
for (const r of records) {
|
|
19
|
+
const g = (byGate[r.gate] ??= { total: 0, outcomes: {}, latencies: [], cached: 0, errors: 0, models: new Set(), confidences: [], tokens: 0, judged: 0, unknown: 0 })
|
|
20
|
+
g.total += 1
|
|
21
|
+
g.outcomes[r.outcome ?? 'unknown'] = (g.outcomes[r.outcome ?? 'unknown'] ?? 0) + 1
|
|
22
|
+
if (r.outcome === 'error') g.errors += 1
|
|
23
|
+
if (r.cached) g.cached += 1
|
|
24
|
+
else if (typeof r.latencyMs === 'number') g.latencies.push(r.latencyMs)
|
|
25
|
+
if (r.model) g.models.add(r.model)
|
|
26
|
+
if (typeof r.confidence === 'number') g.confidences.push(r.confidence)
|
|
27
|
+
// Judged = a request happened (or a cache hit stood in for one). Skipped, error
|
|
28
|
+
// and too-long outcomes cost nothing and are not counted here.
|
|
29
|
+
if (r.model && !r.skipped && r.outcome !== 'error') {
|
|
30
|
+
g.judged += 1
|
|
31
|
+
if (typeof r.inputTokens === 'number') g.tokens += r.inputTokens
|
|
32
|
+
else g.unknown += 1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.entries(byGate).map(([gate, g]) => [
|
|
37
|
+
gate,
|
|
38
|
+
{
|
|
39
|
+
total: g.total,
|
|
40
|
+
outcomes: g.outcomes,
|
|
41
|
+
p50: q(g.latencies, 0.5),
|
|
42
|
+
p95: q(g.latencies, 0.95),
|
|
43
|
+
cacheHitRate: g.total ? g.cached / g.total : 0,
|
|
44
|
+
errors: g.errors,
|
|
45
|
+
models: [...g.models],
|
|
46
|
+
askShareAt: Object.fromEntries([0.5, 0.6, 0.7, 0.8, 0.9].map((t) => [t, g.confidences.length ? g.confidences.filter((c) => c < t).length / g.confidences.length : null])),
|
|
47
|
+
judged: g.judged,
|
|
48
|
+
inputTokens: g.tokens,
|
|
49
|
+
tokensUnknown: g.unknown,
|
|
50
|
+
costTotal: g.tokens * PRICE_PER_INPUT_TOKEN,
|
|
51
|
+
costPerJudged: g.judged - g.unknown ? (g.tokens * PRICE_PER_INPUT_TOKEN) / (g.judged - g.unknown) : null,
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function render(summary) {
|
|
58
|
+
const lines = []
|
|
59
|
+
for (const [gate, s] of Object.entries(summary)) {
|
|
60
|
+
lines.push(`## ${gate} — ${s.total} decisions`)
|
|
61
|
+
lines.push(`outcomes: ${Object.entries(s.outcomes).map(([k, v]) => `${k} ${v}`).join(' · ')}${s.outcomes.skipped ? ` — ${Math.round((100 * s.outcomes.skipped) / s.total)}% never reached Jev` : ''}`)
|
|
62
|
+
lines.push(`latency (uncached): p50 ${s.p50 ?? '—'} ms · p95 ${s.p95 ?? '—'} ms · cache hits ${Math.round(s.cacheHitRate * 100)}% · errors ${s.errors}`)
|
|
63
|
+
if (s.judged) lines.push(`cost: ${s.inputTokens.toLocaleString('en-US')} input tokens over ${s.judged} judged · $${s.costTotal.toFixed(4)} total · ${s.costPerJudged === null ? 'unknown' : `$${s.costPerJudged.toFixed(6)}`} per judged decision at $${(PRICE_PER_INPUT_TOKEN * 1e9).toFixed(0)}/B input tokens (${PRICE_DATE})${s.tokensUnknown ? ` · ${s.tokensUnknown} without usage` : ''}`)
|
|
64
|
+
lines.push(`share that would be "ask" at threshold: ${Object.entries(s.askShareAt).map(([t, v]) => `${t}→${v === null ? '—' : `${Math.round(v * 100)}%`}`).join(' ')}`)
|
|
65
|
+
lines.push(`models: ${s.models.join(', ') || '—'}`, '')
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n') || 'no decisions logged yet'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const report = (dir) => render(summarize(readDecisions(dir)))
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Everything the plugin remembers lives under the harness's plugin data directory:
|
|
2
|
+
// the audit log (HG-10), the per-session cache (HG-12), the promotion counters (HG-13)
|
|
3
|
+
// and the one-block-per-stop marker. Every write is best-effort — a full disk must
|
|
4
|
+
// not turn into a blocked tool call.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const safe = (fn) => {
|
|
10
|
+
try {
|
|
11
|
+
return fn()
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16)
|
|
18
|
+
|
|
19
|
+
// The audit log rotates once at LOG_MAX bytes: decisions.jsonl → decisions.1.jsonl,
|
|
20
|
+
// the previous .1 dropped. Two files bound the disk; `report` reads both.
|
|
21
|
+
export const LOG_MAX = 8 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
export function appendDecision(dir, record) {
|
|
24
|
+
safe(() => {
|
|
25
|
+
mkdirSync(dir, { recursive: true })
|
|
26
|
+
const p = join(dir, 'decisions.jsonl')
|
|
27
|
+
// No exists-then-stat: read the size in one call and let a missing file throw into `safe`.
|
|
28
|
+
const size = safe(() => statSync(p).size) ?? 0
|
|
29
|
+
if (size > LOG_MAX) renameSync(p, join(dir, 'decisions.1.jsonl'))
|
|
30
|
+
appendFileSync(p, `${JSON.stringify(record)}\n`)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readDecisions(dir) {
|
|
35
|
+
return ['decisions.1.jsonl', 'decisions.jsonl']
|
|
36
|
+
.map((f) => safe(() => readFileSync(join(dir, f), 'utf8')) ?? '')
|
|
37
|
+
.flatMap((text) => text.split('\n'))
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((l) => safe(() => JSON.parse(l)))
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Per-session state: cache entries and promotion counters, one JSON file per session.
|
|
44
|
+
export function sessionFile(dir, sessionId) {
|
|
45
|
+
return join(dir, 'sessions', `${(sessionId || 'no-session').replace(/[^\w-]/g, '_')}.json`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadSession(dir, sessionId) {
|
|
49
|
+
return safe(() => JSON.parse(readFileSync(sessionFile(dir, sessionId), 'utf8'))) ?? { cache: {}, prefixes: {}, proposed: [], blockedPrompts: [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Atomic: write beside, then rename, so two hooks racing (parallel tool calls) can
|
|
53
|
+
// lose an update but never leave a torn file. Session files older than SESSION_TTL
|
|
54
|
+
// are pruned on the way, one in ~20 writes.
|
|
55
|
+
export const SESSION_TTL = 7 * 24 * 3600 * 1000
|
|
56
|
+
|
|
57
|
+
export function saveSession(dir, sessionId, state, now = Date.now()) {
|
|
58
|
+
safe(() => {
|
|
59
|
+
const d = join(dir, 'sessions')
|
|
60
|
+
mkdirSync(d, { recursive: true })
|
|
61
|
+
const p = sessionFile(dir, sessionId)
|
|
62
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
63
|
+
writeFileSync(tmp, JSON.stringify(state))
|
|
64
|
+
renameSync(tmp, p)
|
|
65
|
+
if (Math.random() < 0.05) pruneSessions(dir, now)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pruneSessions(dir, now = Date.now()) {
|
|
70
|
+
safe(() => {
|
|
71
|
+
const d = join(dir, 'sessions')
|
|
72
|
+
for (const f of readdirSync(d)) {
|
|
73
|
+
const p = join(d, f)
|
|
74
|
+
if (now - statSync(p).mtimeMs > SESSION_TTL) unlinkSync(p)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function cacheKey(gate, cwd, state) {
|
|
80
|
+
return hash(`${gate}\n${cwd}\n${JSON.stringify(state)}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function cacheGet(session, key, ttlMs, now = Date.now()) {
|
|
84
|
+
const e = session.cache?.[key]
|
|
85
|
+
if (!e) return null
|
|
86
|
+
if (now - e.at > ttlMs) return null
|
|
87
|
+
return e
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function cachePut(session, key, value, now = Date.now()) {
|
|
91
|
+
session.cache ??= {}
|
|
92
|
+
session.cache[key] = { ...value, at: now }
|
|
93
|
+
// Keep the file bounded: drop the oldest beyond 500 entries.
|
|
94
|
+
const keys = Object.keys(session.cache)
|
|
95
|
+
if (keys.length > 500) for (const k of keys.sort((a, b) => session.cache[a].at - session.cache[b].at).slice(0, keys.length - 500)) delete session.cache[k]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Promotion (HG-13): count confident, identical decisions per command prefix.
|
|
99
|
+
export function commandPrefix(command) {
|
|
100
|
+
let s = String(command).trim()
|
|
101
|
+
// Skip what is not the command: leading VAR=value assignments and `cd <dir> &&` /
|
|
102
|
+
// `cd <dir>;` hops — 47% of real commands start with a cd, and a promotion rule for
|
|
103
|
+
// "cd" would be meaningless.
|
|
104
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
105
|
+
// `cd X && cmd`, `cd X; cmd` and `cd X⏎cmd` — the last is how most real commands
|
|
106
|
+
// arrive (9,408 of 12,700 cd-led commands in one maintainer's transcripts).
|
|
107
|
+
const next = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '').replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*/, '')
|
|
108
|
+
if (next === s) break
|
|
109
|
+
s = next
|
|
110
|
+
}
|
|
111
|
+
const words = s.split(/\s+/)
|
|
112
|
+
const first = words[0] ?? ''
|
|
113
|
+
const takesSub = ['git', 'npm', 'npx', 'pnpm', 'yarn', 'docker', 'kubectl', 'gh', 'cargo', 'go', 'make', 'python', 'python3', 'node', 'pip']
|
|
114
|
+
return takesSub.includes(first) && words[1] && !words[1].startsWith('-') ? `${first} ${words[1]}` : first
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What a promoted rule may cover (HG-24, HG-25). A rule is a prefix, so it covers
|
|
118
|
+
// every command that starts that way: it must only ever be proposed for a command
|
|
119
|
+
// that IS that prefix plus arguments — one simple command, no chaining, no
|
|
120
|
+
// substitution — and never for a program that runs whatever follows.
|
|
121
|
+
const NEVER_PROMOTE = new Set(['python', 'python3', 'node', 'deno', 'bun', 'bash', 'sh', 'zsh', 'fish', 'dash', 'ksh', 'sudo', 'doas', 'su', 'env', 'xargs', 'eval', 'exec', 'source', '.', 'perl', 'ruby', 'php', 'lua', 'osascript', 'nohup', 'time', 'timeout', 'command', 'builtin', 'nice', 'watch', 'caffeinate', 'ssh', 'script', 'expect', 'parallel', 'find'])
|
|
122
|
+
|
|
123
|
+
export function promotablePrefix(command) {
|
|
124
|
+
let s = String(command).trim()
|
|
125
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
126
|
+
const next = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '').replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)[ \t]*(?:&&|;|\n)\s*/, '')
|
|
127
|
+
if (next === s) break
|
|
128
|
+
s = next
|
|
129
|
+
}
|
|
130
|
+
// Operators outside quotes make it more than one command: 92.5% of real commands.
|
|
131
|
+
const unquoted = s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
|
|
132
|
+
if (/&&|\|\||;|\||\n|`|\$\(/.test(unquoted)) return null
|
|
133
|
+
const prefix = commandPrefix(s)
|
|
134
|
+
if (!prefix || NEVER_PROMOTE.has(prefix.split(' ')[0])) return null
|
|
135
|
+
return prefix
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function notePrefix(session, prefix, decision, confidence, cfg) {
|
|
139
|
+
if (!prefix || !['allow', 'deny'].includes(decision) || confidence < cfg.promote.confidence) return null
|
|
140
|
+
session.prefixes ??= {}
|
|
141
|
+
session.proposed ??= []
|
|
142
|
+
const p = (session.prefixes[prefix] ??= { allow: 0, deny: 0 })
|
|
143
|
+
p[decision] += 1
|
|
144
|
+
if (p[decision] >= cfg.promote.after && !session.proposed.includes(prefix)) {
|
|
145
|
+
session.proposed.push(prefix)
|
|
146
|
+
return { prefix, decision, count: p[decision] }
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
package/codex/hooks.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "hookgate — calibrated gates for Codex CLI. Every handler fails open: no key, no network or an API error means no decision, and Codex's normal approval flow takes over.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Bash",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" pre-tool-use",
|
|
11
|
+
"timeout": 5,
|
|
12
|
+
"statusMessage": "hookgate: judging the command"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"PostToolUse": [
|
|
18
|
+
{
|
|
19
|
+
"matcher": "WebFetch|WebSearch|Read|Bash",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" post-tool-use",
|
|
24
|
+
"timeout": 5,
|
|
25
|
+
"statusMessage": "hookgate: screening the result for injected instructions"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"Stop": [
|
|
31
|
+
{
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node \"${PLUGIN_ROOT}/bin/hookgate.mjs\" stop",
|
|
36
|
+
"timeout": 5,
|
|
37
|
+
"statusMessage": "hookgate: checking the claim of completion"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -5,26 +5,22 @@
|
|
|
5
5
|
{
|
|
6
6
|
"matcher": "Bash",
|
|
7
7
|
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["pre-tool-use"], "timeout": 5, "statusMessage": "hookgate: judging the command" }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"PostToolUse": [
|
|
13
|
+
{
|
|
14
|
+
"matcher": "WebFetch|WebSearch|Read|Bash",
|
|
15
|
+
"hooks": [
|
|
16
|
+
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["post-tool-use"], "timeout": 5, "statusMessage": "hookgate: screening the result for injected instructions" }
|
|
15
17
|
]
|
|
16
18
|
}
|
|
17
19
|
],
|
|
18
20
|
"Stop": [
|
|
19
21
|
{
|
|
20
22
|
"hooks": [
|
|
21
|
-
{
|
|
22
|
-
"type": "command",
|
|
23
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs",
|
|
24
|
-
"args": ["stop"],
|
|
25
|
-
"timeout": 5,
|
|
26
|
-
"statusMessage": "hookgate: checking the claim of completion"
|
|
27
|
-
}
|
|
23
|
+
{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/hookgate.mjs", "args": ["stop"], "timeout": 5, "statusMessage": "hookgate: checking the claim of completion" }
|
|
28
24
|
]
|
|
29
25
|
}
|
|
30
26
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookgate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Calibrated, sub-second decisions in Claude Code's hooks: a PreToolUse gate on shell commands and a Stop gate on unverified completion, answered by TypeSafe's Jev with a confidence score, escalating to the human when unsure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"hooks",
|
|
12
|
+
"codex",
|
|
12
13
|
".claude-plugin",
|
|
14
|
+
".codex-plugin",
|
|
13
15
|
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
14
17
|
"LICENSE"
|
|
15
18
|
],
|
|
16
19
|
"engines": {
|
|
@@ -18,7 +21,8 @@
|
|
|
18
21
|
},
|
|
19
22
|
"scripts": {
|
|
20
23
|
"build:site": "node site/build.mjs",
|
|
21
|
-
"test": "node bin/hookgate.mjs check",
|
|
24
|
+
"test": "node bin/hookgate.mjs check && node --test",
|
|
25
|
+
"scorecard": "node evals/scorecard.mjs",
|
|
22
26
|
"backlog": "node scripts/backlog.mjs check && node scripts/backlog_test.mjs"
|
|
23
27
|
},
|
|
24
28
|
"keywords": [
|