@suwujs/codex-vault 0.5.3 → 0.7.0
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/README.md +7 -7
- package/README.zh-CN.md +7 -7
- package/bin/codex-vault-run.sh +28 -0
- package/package.json +1 -1
- package/plugin/VERSION +1 -1
- package/plugin/hooks/{classify-message.py → claude/classify-message.py} +4 -6
- package/plugin/hooks/{session-start.py → claude/session-start.py} +3 -30
- package/{vault/.codex-vault/hooks → plugin/hooks/claude}/validate-write.py +4 -5
- package/plugin/hooks/codex/classify-message.py +303 -0
- package/plugin/hooks/codex/session-start.py +400 -0
- package/plugin/hooks/codex/validate-write.py +119 -0
- package/plugin/install.sh +65 -10
- package/vault/.claude/settings.json +3 -3
- package/vault/.codex/hooks.json +3 -15
- package/vault/.codex-vault/hooks/{classify-message.py → claude/classify-message.py} +4 -6
- package/vault/.codex-vault/hooks/{session-start.py → claude/session-start.py} +3 -30
- package/{plugin/hooks → vault/.codex-vault/hooks/claude}/validate-write.py +4 -5
- package/vault/.codex-vault/hooks/codex/classify-message.py +301 -0
- package/vault/.codex-vault/hooks/codex/session-start.py +384 -0
- package/vault/.codex-vault/hooks/codex/validate-write.py +127 -0
- package/vault/AGENTS.md +1 -118
- package/plugin/hooks/session-start.sh +0 -221
- package/vault/.codex-vault/hooks/session-start.sh +0 -221
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session-start hook for Codex CLI — injects vault context into the agent's prompt.
|
|
3
|
+
|
|
4
|
+
Outputs structured JSON: additionalContext for LLM.
|
|
5
|
+
Banner is written to stderr (Codex CLI does not render systemMessage).
|
|
6
|
+
|
|
7
|
+
Dynamic context: adapts git log window, reads full North Star,
|
|
8
|
+
shows all active work, and includes uncommitted changes.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_vault_root():
|
|
21
|
+
"""Find vault root from CWD — check for Home.md/brain/, then vault/ subdir."""
|
|
22
|
+
cwd = os.environ.get("CODEX_PROJECT_DIR", os.getcwd())
|
|
23
|
+
if os.path.isfile(os.path.join(cwd, "Home.md")) or os.path.isdir(os.path.join(cwd, "brain")):
|
|
24
|
+
return cwd
|
|
25
|
+
vault_sub = os.path.join(cwd, "vault")
|
|
26
|
+
if os.path.isdir(vault_sub) and (
|
|
27
|
+
os.path.isfile(os.path.join(vault_sub, "Home.md")) or
|
|
28
|
+
os.path.isdir(os.path.join(vault_sub, "brain"))
|
|
29
|
+
):
|
|
30
|
+
return vault_sub
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_git(args, cwd, timeout=5):
|
|
35
|
+
"""Run git command and return stdout lines."""
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["git"] + args,
|
|
39
|
+
capture_output=True, text=True, cwd=cwd, timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
return result.stdout.strip().splitlines() if result.stdout.strip() else []
|
|
42
|
+
except Exception:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _git_log_oneline(cwd, since=None, max_count=None):
|
|
47
|
+
"""Get git log --oneline entries."""
|
|
48
|
+
args = ["log", "--oneline", "--no-merges"]
|
|
49
|
+
if since:
|
|
50
|
+
args.append(f"--since={since}")
|
|
51
|
+
if max_count:
|
|
52
|
+
args.extend(["-n", str(max_count)])
|
|
53
|
+
return _run_git(args, cwd)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git_status_short(cwd):
|
|
57
|
+
"""Get git status --short output."""
|
|
58
|
+
return _run_git(["status", "--short", "--", "."], cwd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_file(path):
|
|
62
|
+
"""Read file content, return empty string on error."""
|
|
63
|
+
try:
|
|
64
|
+
return Path(path).read_text(encoding="utf-8")
|
|
65
|
+
except Exception:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _find_md_files(vault_dir):
|
|
70
|
+
"""Find all .md files in vault, excluding non-vault directories."""
|
|
71
|
+
exclude = {".git", ".obsidian", "thinking", ".claude", ".codex",
|
|
72
|
+
".codex-vault", ".codex-mem", "node_modules"}
|
|
73
|
+
files = []
|
|
74
|
+
for root, dirs, filenames in os.walk(vault_dir):
|
|
75
|
+
dirs[:] = [d for d in dirs if d not in exclude]
|
|
76
|
+
for f in filenames:
|
|
77
|
+
if f.endswith(".md"):
|
|
78
|
+
rel = os.path.relpath(os.path.join(root, f), vault_dir)
|
|
79
|
+
files.append(f"./{rel}")
|
|
80
|
+
return sorted(files)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _folder_summary(all_files):
|
|
84
|
+
"""Generate folder summary with file counts."""
|
|
85
|
+
folders = Counter()
|
|
86
|
+
for f in all_files:
|
|
87
|
+
parts = f[2:].split("/") # strip ./
|
|
88
|
+
folders[parts[0] if len(parts) > 1 else "."] += 1
|
|
89
|
+
return [f" {folder}/ ({count} files)"
|
|
90
|
+
for folder, count in folders.most_common()]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _key_files(all_files):
|
|
94
|
+
"""Filter for key vault files."""
|
|
95
|
+
pattern = re.compile(
|
|
96
|
+
r"(Home|Index|North Star|Memories|Key Decisions|Patterns|log)\.md$")
|
|
97
|
+
return [f for f in all_files if pattern.search(f)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mtime_ok(vault_dir, rel_path, cutoff):
|
|
101
|
+
"""Check if file was modified after cutoff timestamp."""
|
|
102
|
+
try:
|
|
103
|
+
return os.path.getmtime(os.path.join(vault_dir, rel_path.lstrip("./"))) > cutoff
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _north_star_goal(vault_dir):
|
|
109
|
+
"""Extract first goal from North Star for banner display."""
|
|
110
|
+
ns_path = os.path.join(vault_dir, "brain", "North Star.md")
|
|
111
|
+
if not os.path.isfile(ns_path):
|
|
112
|
+
return None
|
|
113
|
+
content = _read_file(ns_path)
|
|
114
|
+
in_focus = False
|
|
115
|
+
for line in content.splitlines():
|
|
116
|
+
if re.match(r"^## Current Focus", line):
|
|
117
|
+
in_focus = True
|
|
118
|
+
continue
|
|
119
|
+
if in_focus and line.startswith("## "):
|
|
120
|
+
break
|
|
121
|
+
if in_focus and re.match(r"^- .+", line):
|
|
122
|
+
goal = line[2:].strip()
|
|
123
|
+
return goal[:40] if goal else None
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Context builder (→ additionalContext for LLM) ──────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_context(vault_dir):
|
|
131
|
+
lines = []
|
|
132
|
+
lines.append("## Session Context")
|
|
133
|
+
lines.append("")
|
|
134
|
+
|
|
135
|
+
# Date
|
|
136
|
+
lines.append("### Date")
|
|
137
|
+
now = datetime.now()
|
|
138
|
+
lines.append(f"{now.strftime('%Y-%m-%d')} ({now.strftime('%A')})")
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
# North Star — full file
|
|
142
|
+
lines.append("### North Star")
|
|
143
|
+
ns_path = os.path.join(vault_dir, "brain", "North Star.md")
|
|
144
|
+
if os.path.isfile(ns_path):
|
|
145
|
+
lines.append(_read_file(ns_path))
|
|
146
|
+
else:
|
|
147
|
+
lines.append("(No North Star found — create brain/North Star.md to set goals)")
|
|
148
|
+
lines.append("")
|
|
149
|
+
|
|
150
|
+
# Recent changes — adaptive window
|
|
151
|
+
lines.append("### Recent Changes")
|
|
152
|
+
commits_48h = _git_log_oneline(vault_dir, since="48 hours ago")
|
|
153
|
+
if commits_48h:
|
|
154
|
+
lines.append("(last 48 hours)")
|
|
155
|
+
lines.extend(commits_48h[:15])
|
|
156
|
+
else:
|
|
157
|
+
commits_7d = _git_log_oneline(vault_dir, since="7 days ago")
|
|
158
|
+
if commits_7d:
|
|
159
|
+
lines.append("(nothing in 48h — showing last 7 days)")
|
|
160
|
+
lines.extend(commits_7d[:15])
|
|
161
|
+
else:
|
|
162
|
+
lines.append("(nothing recent — showing last 5 commits)")
|
|
163
|
+
commits = _git_log_oneline(vault_dir, max_count=5)
|
|
164
|
+
lines.extend(commits if commits else ["(no git history)"])
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
# Recent operations from log.md
|
|
168
|
+
lines.append("### Recent Operations")
|
|
169
|
+
log_path = os.path.join(vault_dir, "log.md")
|
|
170
|
+
if os.path.isfile(log_path):
|
|
171
|
+
entries = [l for l in _read_file(log_path).splitlines()
|
|
172
|
+
if l.startswith("## [")]
|
|
173
|
+
lines.extend(entries[-5:] if entries else ["(no entries in log.md)"])
|
|
174
|
+
else:
|
|
175
|
+
lines.append("(no log.md)")
|
|
176
|
+
lines.append("")
|
|
177
|
+
|
|
178
|
+
# Active work
|
|
179
|
+
lines.append("### Active Work")
|
|
180
|
+
active_dir = os.path.join(vault_dir, "work", "active")
|
|
181
|
+
if os.path.isdir(active_dir):
|
|
182
|
+
work_files = sorted(f for f in os.listdir(active_dir) if f.endswith(".md"))
|
|
183
|
+
if work_files:
|
|
184
|
+
for f in work_files:
|
|
185
|
+
lines.append(f.replace(".md", ""))
|
|
186
|
+
else:
|
|
187
|
+
lines.append("(none)")
|
|
188
|
+
else:
|
|
189
|
+
lines.append("(no work/active/ directory)")
|
|
190
|
+
lines.append("")
|
|
191
|
+
|
|
192
|
+
# Uncommitted changes
|
|
193
|
+
lines.append("### Uncommitted Changes")
|
|
194
|
+
changes = _git_status_short(vault_dir)
|
|
195
|
+
lines.extend(changes[:20] if changes else ["(working tree clean)"])
|
|
196
|
+
lines.append("")
|
|
197
|
+
|
|
198
|
+
# Recently modified brain files
|
|
199
|
+
lines.append("### Recently Modified Brain Files")
|
|
200
|
+
brain_dir = os.path.join(vault_dir, "brain")
|
|
201
|
+
if os.path.isdir(brain_dir):
|
|
202
|
+
cutoff = datetime.now().timestamp() - 7 * 86400
|
|
203
|
+
recent = sorted(
|
|
204
|
+
f.replace(".md", "")
|
|
205
|
+
for f in os.listdir(brain_dir)
|
|
206
|
+
if f.endswith(".md") and os.path.getmtime(os.path.join(brain_dir, f)) > cutoff
|
|
207
|
+
)
|
|
208
|
+
if recent:
|
|
209
|
+
lines.append("(modified in last 7 days)")
|
|
210
|
+
lines.extend(recent)
|
|
211
|
+
else:
|
|
212
|
+
lines.append("(no recent changes)")
|
|
213
|
+
lines.append("")
|
|
214
|
+
|
|
215
|
+
# Vault file listing — tiered
|
|
216
|
+
lines.append("### Vault Files")
|
|
217
|
+
all_files = _find_md_files(vault_dir)
|
|
218
|
+
n = len(all_files)
|
|
219
|
+
|
|
220
|
+
if n <= 20:
|
|
221
|
+
lines.extend(all_files)
|
|
222
|
+
|
|
223
|
+
elif n <= 50:
|
|
224
|
+
hot = [f for f in all_files
|
|
225
|
+
if not re.match(r"\./sources/|\./work/archive/", f)]
|
|
226
|
+
cold = n - len(hot)
|
|
227
|
+
lines.extend(hot)
|
|
228
|
+
if cold > 0:
|
|
229
|
+
lines.append("")
|
|
230
|
+
lines.append(f"(+ {cold} files in sources/ and work/archive/ — use /recall to search)")
|
|
231
|
+
|
|
232
|
+
elif n <= 150:
|
|
233
|
+
lines.append(f"({n} files — showing summary)")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.extend(_folder_summary(all_files))
|
|
236
|
+
lines.append("")
|
|
237
|
+
lines.append("Recently modified (7 days):")
|
|
238
|
+
cutoff = datetime.now().timestamp() - 7 * 86400
|
|
239
|
+
recent = [f for f in all_files if _mtime_ok(vault_dir, f, cutoff)]
|
|
240
|
+
lines.extend(recent if recent else [" (none)"])
|
|
241
|
+
lines.append("")
|
|
242
|
+
lines.append("Key files:")
|
|
243
|
+
lines.extend(_key_files(all_files))
|
|
244
|
+
|
|
245
|
+
else:
|
|
246
|
+
lines.append(f"({n} files — showing summary)")
|
|
247
|
+
lines.append("")
|
|
248
|
+
lines.extend(_folder_summary(all_files))
|
|
249
|
+
lines.append("")
|
|
250
|
+
lines.append("Recently modified (3 days):")
|
|
251
|
+
cutoff = datetime.now().timestamp() - 3 * 86400
|
|
252
|
+
recent = [f for f in all_files if _mtime_ok(vault_dir, f, cutoff)]
|
|
253
|
+
lines.extend(recent if recent else [" (none)"])
|
|
254
|
+
lines.append("")
|
|
255
|
+
lines.append("Key files:")
|
|
256
|
+
lines.extend(_key_files(all_files))
|
|
257
|
+
lines.append("")
|
|
258
|
+
lines.append("Use /recall <topic> to search the vault.")
|
|
259
|
+
|
|
260
|
+
return "\n".join(lines)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── ANSI colors (oh-my-codex style) ──────────────────────────────────
|
|
264
|
+
|
|
265
|
+
RESET = "\x1b[0m"
|
|
266
|
+
BOLD = "\x1b[1m"
|
|
267
|
+
DIM = "\x1b[2m"
|
|
268
|
+
CYAN = "\x1b[36m"
|
|
269
|
+
GREEN = "\x1b[32m"
|
|
270
|
+
YELLOW = "\x1b[33m"
|
|
271
|
+
SEP = f" {DIM}|{RESET} "
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _c(code, text):
|
|
275
|
+
return f"{code}{text}{RESET}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── Banner builder (→ stderr for terminal) ────────────────────────────
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _build_banner(vault_dir):
|
|
282
|
+
# --- Line 1: statusline ---
|
|
283
|
+
elements = []
|
|
284
|
+
|
|
285
|
+
# Git branch
|
|
286
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], vault_dir)
|
|
287
|
+
if branch:
|
|
288
|
+
elements.append(_c(CYAN, branch[0]))
|
|
289
|
+
|
|
290
|
+
# North Star goal
|
|
291
|
+
goal = _north_star_goal(vault_dir)
|
|
292
|
+
if goal:
|
|
293
|
+
elements.append(f"\U0001f3af {goal}")
|
|
294
|
+
else:
|
|
295
|
+
elements.append(_c(DIM, "\U0001f3af no goal set"))
|
|
296
|
+
|
|
297
|
+
# Active work
|
|
298
|
+
active_dir = os.path.join(vault_dir, "work", "active")
|
|
299
|
+
work_files = sorted(
|
|
300
|
+
f.replace(".md", "")
|
|
301
|
+
for f in os.listdir(active_dir) if f.endswith(".md")
|
|
302
|
+
) if os.path.isdir(active_dir) else []
|
|
303
|
+
if work_files:
|
|
304
|
+
names = ", ".join(work_files[:3])
|
|
305
|
+
suffix = f" +{len(work_files) - 3}" if len(work_files) > 3 else ""
|
|
306
|
+
elements.append(_c(GREEN, f"active:{len(work_files)}") + _c(DIM, f" {names}{suffix}"))
|
|
307
|
+
else:
|
|
308
|
+
elements.append(_c(DIM, "active:0"))
|
|
309
|
+
|
|
310
|
+
# Changes
|
|
311
|
+
changes = _git_status_short(vault_dir)
|
|
312
|
+
if changes:
|
|
313
|
+
elements.append(_c(YELLOW, f"changes:{len(changes)}"))
|
|
314
|
+
else:
|
|
315
|
+
elements.append(_c(GREEN, "clean"))
|
|
316
|
+
|
|
317
|
+
label = _c(BOLD, "[Vault]")
|
|
318
|
+
statusline = label + " " + SEP.join(elements)
|
|
319
|
+
|
|
320
|
+
# --- Line 2+: recent commits ---
|
|
321
|
+
lines = ["", statusline]
|
|
322
|
+
commits = _git_log_oneline(vault_dir, since="7 days ago")
|
|
323
|
+
if not commits:
|
|
324
|
+
commits = _git_log_oneline(vault_dir, max_count=3)
|
|
325
|
+
if commits:
|
|
326
|
+
for c in commits[:3]:
|
|
327
|
+
# hash in cyan, message in dim
|
|
328
|
+
parts = c.split(" ", 1)
|
|
329
|
+
if len(parts) == 2:
|
|
330
|
+
lines.append(f" {_c(DIM, parts[0])} {_c(DIM, parts[1])}")
|
|
331
|
+
else:
|
|
332
|
+
lines.append(f" {_c(DIM, c)}")
|
|
333
|
+
|
|
334
|
+
# --- Line: vault file count ---
|
|
335
|
+
all_files = _find_md_files(vault_dir)
|
|
336
|
+
lines.append(_c(DIM, f" {len(all_files)} notes"))
|
|
337
|
+
lines.append("")
|
|
338
|
+
|
|
339
|
+
return "\n".join(lines)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── Main ───────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main():
|
|
346
|
+
vault_dir = _find_vault_root()
|
|
347
|
+
if not vault_dir:
|
|
348
|
+
output = {
|
|
349
|
+
"hookSpecificOutput": {
|
|
350
|
+
"hookEventName": "SessionStart",
|
|
351
|
+
"additionalContext": "## Session Context\n\n(No vault found)"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
sys.stdout.write(json.dumps(output) + "\n")
|
|
355
|
+
sys.exit(0)
|
|
356
|
+
|
|
357
|
+
# Read hook input for session metadata
|
|
358
|
+
try:
|
|
359
|
+
event = json.load(sys.stdin)
|
|
360
|
+
except Exception:
|
|
361
|
+
event = {}
|
|
362
|
+
|
|
363
|
+
context = _build_context(vault_dir)
|
|
364
|
+
|
|
365
|
+
# Build a short summary for user-facing display
|
|
366
|
+
goal = _north_star_goal(vault_dir)
|
|
367
|
+
active_dir = os.path.join(vault_dir, "work", "active")
|
|
368
|
+
work_count = len([f for f in os.listdir(active_dir) if f.endswith(".md")]) if os.path.isdir(active_dir) else 0
|
|
369
|
+
changes = _git_status_short(vault_dir)
|
|
370
|
+
all_files = _find_md_files(vault_dir)
|
|
371
|
+
|
|
372
|
+
summary_parts = ["[Vault]"]
|
|
373
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], vault_dir)
|
|
374
|
+
if branch:
|
|
375
|
+
summary_parts.append(branch[0])
|
|
376
|
+
if goal:
|
|
377
|
+
summary_parts.append(f"goal: {goal}")
|
|
378
|
+
summary_parts.append(f"active:{work_count}")
|
|
379
|
+
summary_parts.append(f"changes:{len(changes)}")
|
|
380
|
+
summary_parts.append(f"{len(all_files)} notes")
|
|
381
|
+
summary = " | ".join(summary_parts)
|
|
382
|
+
|
|
383
|
+
output = {
|
|
384
|
+
"hookSpecificOutput": {
|
|
385
|
+
"hookEventName": "SessionStart",
|
|
386
|
+
"additionalContext": context
|
|
387
|
+
},
|
|
388
|
+
"systemMessage": summary
|
|
389
|
+
}
|
|
390
|
+
sys.stdout.write(json.dumps(output) + "\n")
|
|
391
|
+
sys.stdout.flush()
|
|
392
|
+
sys.exit(0)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
try:
|
|
397
|
+
main()
|
|
398
|
+
except Exception:
|
|
399
|
+
# Never block session start
|
|
400
|
+
sys.exit(0)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook for Codex CLI — Bash command validation.
|
|
3
|
+
|
|
4
|
+
Checks Bash command output for hard failures (command not found,
|
|
5
|
+
permission denied, missing paths) and non-zero exit codes with
|
|
6
|
+
informative output. Modeled after oh-my-codex's native PostToolUse.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
HARD_FAILURE_PATTERNS = re.compile(
|
|
14
|
+
r"command not found|permission denied|no such file or directory",
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safe_string(value):
|
|
20
|
+
return value if isinstance(value, str) else ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_int(value):
|
|
24
|
+
if isinstance(value, int):
|
|
25
|
+
return value
|
|
26
|
+
if isinstance(value, str) and value.strip().lstrip("-").isdigit():
|
|
27
|
+
return int(value.strip())
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_tool_response(raw):
|
|
32
|
+
"""Try to parse tool_response as JSON dict."""
|
|
33
|
+
if isinstance(raw, dict):
|
|
34
|
+
return raw
|
|
35
|
+
if isinstance(raw, str):
|
|
36
|
+
try:
|
|
37
|
+
parsed = json.loads(raw)
|
|
38
|
+
if isinstance(parsed, dict):
|
|
39
|
+
return parsed
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
try:
|
|
47
|
+
payload = json.load(sys.stdin)
|
|
48
|
+
except (ValueError, EOFError, OSError):
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
|
|
51
|
+
tool_name = _safe_string(payload.get("tool_name", "")).strip()
|
|
52
|
+
if tool_name != "Bash":
|
|
53
|
+
sys.exit(0)
|
|
54
|
+
|
|
55
|
+
# Extract command and response
|
|
56
|
+
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
57
|
+
command = _safe_string(tool_input.get("command", "")).strip()
|
|
58
|
+
|
|
59
|
+
raw_response = payload.get("tool_response")
|
|
60
|
+
parsed = _parse_tool_response(raw_response)
|
|
61
|
+
|
|
62
|
+
exit_code = None
|
|
63
|
+
stdout_text = ""
|
|
64
|
+
stderr_text = ""
|
|
65
|
+
|
|
66
|
+
if parsed:
|
|
67
|
+
exit_code = _safe_int(parsed.get("exit_code")) or _safe_int(parsed.get("exitCode"))
|
|
68
|
+
stdout_text = _safe_string(parsed.get("stdout", "")).strip()
|
|
69
|
+
stderr_text = _safe_string(parsed.get("stderr", "")).strip()
|
|
70
|
+
else:
|
|
71
|
+
stdout_text = _safe_string(raw_response).strip()
|
|
72
|
+
|
|
73
|
+
combined = f"{stderr_text}\n{stdout_text}".strip()
|
|
74
|
+
if not combined:
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
# Check for hard failures
|
|
78
|
+
if HARD_FAILURE_PATTERNS.search(combined):
|
|
79
|
+
output = {
|
|
80
|
+
"decision": "block",
|
|
81
|
+
"reason": "Bash output indicates a command/setup failure that should be fixed before retrying.",
|
|
82
|
+
"hookSpecificOutput": {
|
|
83
|
+
"hookEventName": "PostToolUse",
|
|
84
|
+
"additionalContext": (
|
|
85
|
+
"Bash reported `command not found`, `permission denied`, or a missing file/path. "
|
|
86
|
+
"Verify the command, dependency installation, PATH, file permissions, "
|
|
87
|
+
"and referenced paths before retrying."
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
sys.stdout.write(json.dumps(output) + "\n")
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
# Check for non-zero exit code with informative output
|
|
96
|
+
if exit_code is not None and exit_code != 0 and len(combined) > 0:
|
|
97
|
+
output = {
|
|
98
|
+
"decision": "block",
|
|
99
|
+
"reason": "Bash command returned a non-zero exit code but produced useful output that should be reviewed before retrying.",
|
|
100
|
+
"hookSpecificOutput": {
|
|
101
|
+
"hookEventName": "PostToolUse",
|
|
102
|
+
"additionalContext": (
|
|
103
|
+
"The Bash output appears informative despite the non-zero exit code. "
|
|
104
|
+
"Review and report the output before retrying instead of assuming the command simply failed."
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
sys.stdout.write(json.dumps(output) + "\n")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
sys.exit(0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
try:
|
|
117
|
+
main()
|
|
118
|
+
except Exception:
|
|
119
|
+
sys.exit(0)
|
package/plugin/install.sh
CHANGED
|
@@ -90,13 +90,14 @@ fi
|
|
|
90
90
|
# --- Copy hooks into vault (makes vault self-contained) ---
|
|
91
91
|
|
|
92
92
|
HOOKS_DIR="$VAULT_DIR/.codex-vault/hooks"
|
|
93
|
-
mkdir -p "$HOOKS_DIR"
|
|
94
|
-
cp "$REPO_DIR/plugin/hooks/"* "$HOOKS_DIR/"
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
mkdir -p "$HOOKS_DIR/claude" "$HOOKS_DIR/codex"
|
|
94
|
+
cp "$REPO_DIR/plugin/hooks/claude/"* "$HOOKS_DIR/claude/"
|
|
95
|
+
cp "$REPO_DIR/plugin/hooks/codex/"* "$HOOKS_DIR/codex/"
|
|
96
|
+
chmod +x "$HOOKS_DIR/claude/"*.py "$HOOKS_DIR/codex/"*.py 2>/dev/null || true
|
|
97
|
+
echo "[+] Hook scripts copied to vault/.codex-vault/hooks/{claude,codex}/"
|
|
97
98
|
echo ""
|
|
98
99
|
|
|
99
|
-
# --- Helper: merge hooks into existing settings.json ---
|
|
100
|
+
# --- Helper: merge hooks into existing settings.json (Claude Code) ---
|
|
100
101
|
# Uses python3 for reliable JSON manipulation.
|
|
101
102
|
|
|
102
103
|
merge_hooks_json() {
|
|
@@ -153,6 +154,60 @@ with open(target_file, "w") as f:
|
|
|
153
154
|
PYEOF
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
# --- Helper: merge hooks into existing hooks.json (Codex CLI) ---
|
|
158
|
+
# Codex CLI differences:
|
|
159
|
+
# - SessionStart matcher: "startup|resume" (no compact)
|
|
160
|
+
# - No PostToolUse hook (Codex only supports Bash matcher, not Write|Edit)
|
|
161
|
+
|
|
162
|
+
merge_codex_hooks_json() {
|
|
163
|
+
local target_file="$1"
|
|
164
|
+
local hooks_rel="$2"
|
|
165
|
+
|
|
166
|
+
CVAULT_TARGET_FILE="$target_file" CVAULT_HOOKS_REL="$hooks_rel" python3 <<'PYEOF'
|
|
167
|
+
import json, os
|
|
168
|
+
|
|
169
|
+
target_file = os.environ["CVAULT_TARGET_FILE"]
|
|
170
|
+
hooks_rel = os.environ["CVAULT_HOOKS_REL"]
|
|
171
|
+
|
|
172
|
+
new_hooks = {
|
|
173
|
+
"SessionStart": [{
|
|
174
|
+
"matcher": "startup|resume",
|
|
175
|
+
"hooks": [{"type": "command", "command": f"python3 {hooks_rel}/session-start.py", "timeout": 30,
|
|
176
|
+
"statusMessage": "\U0001f4da Codex-Vault loading..."}]
|
|
177
|
+
}],
|
|
178
|
+
"UserPromptSubmit": [{
|
|
179
|
+
"hooks": [{"type": "command", "command": f"python3 {hooks_rel}/classify-message.py", "timeout": 15}]
|
|
180
|
+
}],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if os.path.isfile(target_file):
|
|
184
|
+
with open(target_file) as f:
|
|
185
|
+
existing = json.load(f)
|
|
186
|
+
else:
|
|
187
|
+
existing = {}
|
|
188
|
+
|
|
189
|
+
if "hooks" not in existing:
|
|
190
|
+
existing["hooks"] = {}
|
|
191
|
+
|
|
192
|
+
for event, entries in new_hooks.items():
|
|
193
|
+
if event not in existing["hooks"]:
|
|
194
|
+
existing["hooks"][event] = entries
|
|
195
|
+
else:
|
|
196
|
+
existing_cmds = set()
|
|
197
|
+
for rule in existing["hooks"][event]:
|
|
198
|
+
for h in rule.get("hooks", []):
|
|
199
|
+
existing_cmds.add(h.get("command", ""))
|
|
200
|
+
for entry in entries:
|
|
201
|
+
cmds = [h.get("command", "") for h in entry.get("hooks", [])]
|
|
202
|
+
if not any(c in existing_cmds for c in cmds):
|
|
203
|
+
existing["hooks"][event].append(entry)
|
|
204
|
+
|
|
205
|
+
with open(target_file, "w") as f:
|
|
206
|
+
json.dump(existing, f, indent=2)
|
|
207
|
+
f.write("\n")
|
|
208
|
+
PYEOF
|
|
209
|
+
}
|
|
210
|
+
|
|
156
211
|
# --- Helper: append codex-vault section to instruction file ---
|
|
157
212
|
|
|
158
213
|
append_instructions() {
|
|
@@ -208,7 +263,7 @@ setup_claude() {
|
|
|
208
263
|
if [ "$MODE" = "integrated" ]; then
|
|
209
264
|
# Integrated: merge hooks into project root .claude/settings.json
|
|
210
265
|
mkdir -p "$CONFIG_DIR/.claude"
|
|
211
|
-
merge_hooks_json "$CONFIG_DIR/.claude/settings.json" "$HOOKS_REL"
|
|
266
|
+
merge_hooks_json "$CONFIG_DIR/.claude/settings.json" "$HOOKS_REL/claude"
|
|
212
267
|
echo " [+] .claude/settings.json (hooks merged at project root)"
|
|
213
268
|
|
|
214
269
|
# Append instructions to project root CLAUDE.md
|
|
@@ -219,7 +274,7 @@ setup_claude() {
|
|
|
219
274
|
install_skills "$CONFIG_DIR" ".claude"
|
|
220
275
|
else
|
|
221
276
|
# Standalone: write directly into vault/ (original behavior)
|
|
222
|
-
merge_hooks_json "$VAULT_DIR/.claude/settings.json" "$HOOKS_REL"
|
|
277
|
+
merge_hooks_json "$VAULT_DIR/.claude/settings.json" "$HOOKS_REL/claude"
|
|
223
278
|
echo " [+] .claude/settings.json (3 hooks)"
|
|
224
279
|
|
|
225
280
|
# Install skills into vault/.claude/skills/
|
|
@@ -270,7 +325,7 @@ setup_codex() {
|
|
|
270
325
|
if [ "$MODE" = "integrated" ]; then
|
|
271
326
|
# Integrated: merge hooks into project root .codex/hooks.json
|
|
272
327
|
mkdir -p "$CONFIG_DIR/.codex"
|
|
273
|
-
|
|
328
|
+
merge_codex_hooks_json "$CONFIG_DIR/.codex/hooks.json" "$HOOKS_REL/codex"
|
|
274
329
|
echo " [+] .codex/hooks.json (hooks merged at project root)"
|
|
275
330
|
|
|
276
331
|
if [ "$has_claude" = true ]; then
|
|
@@ -290,8 +345,8 @@ setup_codex() {
|
|
|
290
345
|
# Standalone: write directly into vault/ (original behavior)
|
|
291
346
|
mkdir -p "$VAULT_DIR/.codex"
|
|
292
347
|
|
|
293
|
-
|
|
294
|
-
echo " [+] .codex/hooks.json (
|
|
348
|
+
merge_codex_hooks_json "$VAULT_DIR/.codex/hooks.json" "$HOOKS_REL/codex"
|
|
349
|
+
echo " [+] .codex/hooks.json (2 hooks)"
|
|
295
350
|
|
|
296
351
|
# Enable hooks feature flag
|
|
297
352
|
enable_codex_hooks "$VAULT_DIR"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "python3 .codex-vault/hooks/session-start.py",
|
|
9
|
+
"command": "python3 .codex-vault/hooks/claude/session-start.py",
|
|
10
10
|
"timeout": 30
|
|
11
11
|
}
|
|
12
12
|
]
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"hooks": [
|
|
18
18
|
{
|
|
19
19
|
"type": "command",
|
|
20
|
-
"command": "python3 .codex-vault/hooks/classify-message.py",
|
|
20
|
+
"command": "python3 .codex-vault/hooks/claude/classify-message.py",
|
|
21
21
|
"timeout": 15
|
|
22
22
|
}
|
|
23
23
|
]
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"hooks": [
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
|
-
"command": "python3 .codex-vault/hooks/validate-write.py",
|
|
32
|
+
"command": "python3 .codex-vault/hooks/claude/validate-write.py",
|
|
33
33
|
"timeout": 15
|
|
34
34
|
}
|
|
35
35
|
]
|
package/vault/.codex/hooks.json
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "python3 .codex-vault/hooks/session-start.py",
|
|
9
|
+
"command": "python3 .codex-vault/hooks/codex/session-start.py",
|
|
10
10
|
"timeout": 30,
|
|
11
|
-
"statusMessage": "
|
|
11
|
+
"statusMessage": "\ud83d\udcda Codex-Vault loading..."
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
14
|
}
|
|
@@ -18,19 +18,7 @@
|
|
|
18
18
|
"hooks": [
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
|
-
"command": "python3 .codex-vault/hooks/classify-message.py",
|
|
22
|
-
"timeout": 15
|
|
23
|
-
}
|
|
24
|
-
]
|
|
25
|
-
}
|
|
26
|
-
],
|
|
27
|
-
"PostToolUse": [
|
|
28
|
-
{
|
|
29
|
-
"matcher": "Write|Edit",
|
|
30
|
-
"hooks": [
|
|
31
|
-
{
|
|
32
|
-
"type": "command",
|
|
33
|
-
"command": "python3 .codex-vault/hooks/validate-write.py",
|
|
21
|
+
"command": "python3 .codex-vault/hooks/codex/classify-message.py",
|
|
34
22
|
"timeout": 15
|
|
35
23
|
}
|
|
36
24
|
]
|