@suwujs/codex-vault 0.5.3 → 0.6.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/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 +305 -0
- package/plugin/hooks/codex/session-start.py +387 -0
- package/plugin/hooks/codex/validate-write.py +127 -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 +305 -0
- package/vault/.codex-vault/hooks/codex/session-start.py +387 -0
- package/vault/.codex-vault/hooks/codex/validate-write.py +127 -0
- package/vault/.codex-vault/hooks/session-start.sh +0 -221
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
json.dump(output, sys.stdout)
|
|
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
|
+
banner = _build_banner(vault_dir)
|
|
365
|
+
|
|
366
|
+
# Codex CLI: systemMessage not rendered by TUI,
|
|
367
|
+
# use stderr for terminal visibility (best effort)
|
|
368
|
+
sys.stderr.write(banner + "\n")
|
|
369
|
+
|
|
370
|
+
output = {
|
|
371
|
+
"hookSpecificOutput": {
|
|
372
|
+
"hookEventName": "SessionStart",
|
|
373
|
+
"additionalContext": context
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
json.dump(output, sys.stdout)
|
|
378
|
+
sys.stdout.flush()
|
|
379
|
+
sys.exit(0)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
if __name__ == "__main__":
|
|
383
|
+
try:
|
|
384
|
+
main()
|
|
385
|
+
except Exception:
|
|
386
|
+
# Never block session start
|
|
387
|
+
sys.exit(0)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Post-write validation for vault notes — Codex CLI version.
|
|
3
|
+
|
|
4
|
+
Checks frontmatter and wikilinks on any .md file written to the vault.
|
|
5
|
+
Outputs hookSpecificOutput for Codex CLI. Feedback via stderr
|
|
6
|
+
(Codex CLI does not render systemMessage).
|
|
7
|
+
|
|
8
|
+
Note: Codex CLI PostToolUse only supports Bash matcher, so this script
|
|
9
|
+
will only run when triggered by a Bash tool writing .md files. The
|
|
10
|
+
validation logic is identical to the Claude Code version.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_log_format(content):
|
|
20
|
+
"""Validate log.md entry format: ## [YYYY-MM-DD] <type> | <title>"""
|
|
21
|
+
warnings = []
|
|
22
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
23
|
+
if line.startswith("## ") and not line.startswith("## ["):
|
|
24
|
+
# Heading that looks like a log entry but missing date brackets
|
|
25
|
+
if any(t in line.lower() for t in ["ingest", "session", "query", "maintenance", "decision", "archive"]):
|
|
26
|
+
warnings.append(f"Line {i}: log entry missing date format — expected `## [YYYY-MM-DD] <type> | <title>`")
|
|
27
|
+
elif line.startswith("## ["):
|
|
28
|
+
if not re.match(r"^## \[\d{4}-\d{2}-\d{2}\] \w+", line):
|
|
29
|
+
warnings.append(f"Line {i}: malformed log entry — expected `## [YYYY-MM-DD] <type> | <title>`")
|
|
30
|
+
return warnings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
try:
|
|
35
|
+
input_data = json.load(sys.stdin)
|
|
36
|
+
except (ValueError, EOFError, OSError):
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
|
|
39
|
+
tool_input = input_data.get("tool_input")
|
|
40
|
+
if not isinstance(tool_input, dict):
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
|
|
43
|
+
file_path = tool_input.get("file_path", "")
|
|
44
|
+
if not isinstance(file_path, str) or not file_path:
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
|
|
47
|
+
if not file_path.endswith(".md"):
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
|
|
50
|
+
normalized = file_path.replace("\\", "/")
|
|
51
|
+
basename = os.path.basename(normalized)
|
|
52
|
+
|
|
53
|
+
# Skip non-vault files
|
|
54
|
+
skip_names = {"README.md", "CHANGELOG.md", "CONTRIBUTING.md", "CLAUDE.md", "AGENTS.md", "LICENSE"}
|
|
55
|
+
if basename in skip_names:
|
|
56
|
+
sys.exit(0)
|
|
57
|
+
if basename.startswith("README.") and basename.endswith(".md"):
|
|
58
|
+
sys.exit(0)
|
|
59
|
+
|
|
60
|
+
skip_paths = [".claude/", ".codex/", ".codex-vault/", ".mind/", "templates/", "thinking/", "node_modules/", "plugin/", "docs/"]
|
|
61
|
+
if any(skip in normalized for skip in skip_paths):
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
|
|
64
|
+
warnings = []
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
content = Path(file_path).read_text(encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
if not content.startswith("---"):
|
|
70
|
+
warnings.append("Missing YAML frontmatter")
|
|
71
|
+
else:
|
|
72
|
+
parts = content.split("---", 2)
|
|
73
|
+
if len(parts) >= 3:
|
|
74
|
+
fm = parts[1]
|
|
75
|
+
if "date:" not in fm and basename != "log.md":
|
|
76
|
+
warnings.append("Missing `date` in frontmatter")
|
|
77
|
+
if "tags:" not in fm:
|
|
78
|
+
warnings.append("Missing `tags` in frontmatter")
|
|
79
|
+
if "description:" not in fm:
|
|
80
|
+
warnings.append("Missing `description` in frontmatter (~150 chars)")
|
|
81
|
+
|
|
82
|
+
if len(content) > 300 and "[[" not in content:
|
|
83
|
+
warnings.append("No [[wikilinks]] found — every note should link to at least one other note")
|
|
84
|
+
|
|
85
|
+
# Check for unfilled template placeholders
|
|
86
|
+
placeholders = re.findall(r"\{\{[^}]+\}\}", content)
|
|
87
|
+
if placeholders:
|
|
88
|
+
examples = ", ".join(placeholders[:3])
|
|
89
|
+
warnings.append(f"Unfilled template placeholders found: {examples}")
|
|
90
|
+
|
|
91
|
+
# Validate log.md format
|
|
92
|
+
if basename == "log.md":
|
|
93
|
+
log_warnings = _check_log_format(content)
|
|
94
|
+
warnings.extend(log_warnings)
|
|
95
|
+
|
|
96
|
+
except Exception:
|
|
97
|
+
sys.exit(0)
|
|
98
|
+
|
|
99
|
+
if warnings:
|
|
100
|
+
hint_list = "\n".join(f" - {w}" for w in warnings)
|
|
101
|
+
count = len(warnings)
|
|
102
|
+
first = warnings[0]
|
|
103
|
+
if count == 1:
|
|
104
|
+
feedback = f"\u26a0\ufe0f vault: {basename} — {first}"
|
|
105
|
+
else:
|
|
106
|
+
feedback = f"\u26a0\ufe0f vault: {basename} — {first} (+{count - 1} more)"
|
|
107
|
+
|
|
108
|
+
# Codex CLI: use stderr for feedback (no systemMessage rendering)
|
|
109
|
+
sys.stderr.write(f" {feedback}\n")
|
|
110
|
+
|
|
111
|
+
output = {
|
|
112
|
+
"hookSpecificOutput": {
|
|
113
|
+
"hookEventName": "PostToolUse",
|
|
114
|
+
"additionalContext": f"Vault warnings for `{basename}`:\n{hint_list}\nFix these before moving on."
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
json.dump(output, sys.stdout)
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
try:
|
|
125
|
+
main()
|
|
126
|
+
except Exception:
|
|
127
|
+
sys.exit(0)
|