Skip to content

Commit 29dba09

Browse files
authored
refactor: migrate entity storage from JSON to flat markdown files (#91)
* refactor: migrate entity storage from JSON to flat markdown files Extract shared entity I/O into lib/entity_io.py with markdown+frontmatter serialization, slugified filenames, and atomic writes. Simplify save and retrieve scripts to use the shared library. Replace the three arbitrary entity categories (strategy/recovery/optimization) with a single `type` field (e.g. guideline) used as the filesystem directory grouping. * fix(entity-io): use start-of-line match for rationale header parsing Addresses CodeRabbit review finding: Content containing "## Rationale" will be incorrectly parsed * fix(entity-io): prevent concurrent write collisions on same slug Use tempfile.mkstemp for unique temp files and os.open with O_EXCL for atomic target creation, retrying candidate selection on race. Addresses CodeRabbit review finding: Concurrent writes with the same slug can collide and lose data * fix(entity-io): reject unsafe entity type values before joining paths Validate entity_type against a safe pattern to prevent path traversal (e.g. ../outside or /tmp/escape) when constructing the subdirectory. Addresses CodeRabbit review finding: Reject unsafe type values before joining paths * fix: resolve CI failures for formatting and type-checking Run ruff format on entity_io.py and add entity_io to mypy ignore_missing_imports (runtime sys.path import).
1 parent 1575668 commit 29dba09

8 files changed

Lines changed: 403 additions & 248 deletions

File tree

plugins/kaizen/README.md

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ claude --plugin-dir /path/to/kaizen/repo/plugins/kaizen
3030
### Entity Retrieval (Automatic)
3131

3232
When you submit a prompt, the plugin automatically:
33-
1. Loads all stored entities from `.kaizen/entities.json`
33+
1. Loads all stored entities from `.kaizen/entities/` (one markdown file per entity)
3434
2. Formats and injects them into the conversation context
3535
3. Claude applies relevant entities to the current task
3636

@@ -41,7 +41,7 @@ By default, you must manually invoke the `/kaizen:learn` skill to extract entiti
4141
2. Invoke `/kaizen:learn`
4242
3. The plugin analyzes the conversation trajectory
4343
4. Extracts actionable entities from what worked/failed
44-
5. Saves new entities to `.kaizen/entities.json`
44+
5. Saves new entities as markdown files in `.kaizen/entities/{type}/`
4545

4646
## Example Walkthrough
4747

@@ -87,24 +87,33 @@ Manually invoke to export the current conversation as a trajectory JSON file:
8787

8888
## Entities Storage
8989

90-
Entities are stored in `.kaizen/entities.json`:
91-
92-
```json
93-
{
94-
"entities": [
95-
{
96-
"content": "Use Python PIL/Pillow for image metadata extraction in sandboxed environments",
97-
"rationale": "System tools like exiftool may not be available",
98-
"category": "strategy",
99-
"trigger": "When extracting image metadata in containerized environments"
100-
}
101-
]
102-
}
90+
Entities are stored as individual markdown files in `.kaizen/entities/`, nested by type:
91+
92+
```
93+
.kaizen/entities/
94+
guideline/
95+
use-python-pil-for-image-metadata-extraction.md
96+
cache-api-responses-locally.md
97+
```
98+
99+
Each file uses markdown with YAML frontmatter:
100+
101+
```markdown
102+
---
103+
type: guideline
104+
trigger: When extracting image metadata in containerized environments
105+
---
106+
107+
Use Python PIL/Pillow for image metadata extraction in sandboxed environments
108+
109+
## Rationale
110+
111+
System tools like exiftool may not be available
103112
```
104113

105114
## Environment Variables
106115

107-
- `KAIZEN_ENTITIES_FILE`: Override the default entities storage location
116+
- `KAIZEN_ENTITIES_DIR`: Override the default entities directory location
108117
- `CLAUDE_PROJECT_ROOT`: Set by Claude Code, used to locate project-level entities
109118

110119
## Verification

plugins/kaizen/lib/__init__.py

Whitespace-only changes.

plugins/kaizen/lib/entity_io.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
"""Shared entity I/O utilities for the Kaizen plugin.
2+
3+
Handles reading and writing entities as flat markdown files with YAML
4+
frontmatter, organized in type-nested directories.
5+
"""
6+
7+
import datetime
8+
import getpass
9+
import os
10+
import re
11+
import tempfile
12+
from pathlib import Path
13+
14+
15+
# ---------------------------------------------------------------------------
16+
# Logging
17+
# ---------------------------------------------------------------------------
18+
19+
20+
def _get_log_dir():
21+
"""Get user-scoped log directory with restrictive permissions."""
22+
try:
23+
uid = os.getuid()
24+
except AttributeError:
25+
uid = getpass.getuser()
26+
log_dir = os.path.join(tempfile.gettempdir(), f"kaizen-{uid}")
27+
os.makedirs(log_dir, mode=0o700, exist_ok=True)
28+
return log_dir
29+
30+
31+
_LOG_FILE = os.path.join(_get_log_dir(), "kaizen-plugin.log")
32+
33+
34+
def log(component, message):
35+
"""Append a timestamped message to the shared log file.
36+
37+
Args:
38+
component: Short label like "retrieve" or "save".
39+
message: The log line.
40+
"""
41+
if not os.environ.get("KAIZEN_DEBUG"):
42+
return
43+
try:
44+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
45+
with open(_LOG_FILE, "a", encoding="utf-8") as f:
46+
f.write(f"[{timestamp}] [{component}] {message}\n")
47+
except OSError:
48+
pass
49+
50+
51+
# ---------------------------------------------------------------------------
52+
# Directory discovery
53+
# ---------------------------------------------------------------------------
54+
55+
56+
def find_entities_dir():
57+
"""Locate the entities directory.
58+
59+
Search order:
60+
1. ``KAIZEN_ENTITIES_DIR`` env var (authoritative, no fallback)
61+
2. ``{CLAUDE_PROJECT_ROOT}/.kaizen/entities/``
62+
3. ``.kaizen/entities/`` (cwd)
63+
64+
Returns:
65+
Path to the directory if it exists, else ``None``.
66+
"""
67+
env_dir = os.environ.get("KAIZEN_ENTITIES_DIR")
68+
if env_dir:
69+
p = Path(env_dir)
70+
return p if p.is_dir() else None
71+
72+
project_root = os.environ.get("CLAUDE_PROJECT_ROOT")
73+
candidates = []
74+
if project_root:
75+
candidates.append(Path(project_root) / ".kaizen" / "entities")
76+
candidates.append(Path(".kaizen") / "entities")
77+
78+
for c in candidates:
79+
if c.is_dir():
80+
return c
81+
return None
82+
83+
84+
def get_default_entities_dir():
85+
"""Return (and create) the default entities directory.
86+
87+
Prefers ``{CLAUDE_PROJECT_ROOT}/.kaizen/entities/``, falls back to
88+
``.kaizen/entities/``.
89+
"""
90+
project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "")
91+
if project_root:
92+
base = Path(project_root) / ".kaizen" / "entities"
93+
else:
94+
base = Path(".kaizen") / "entities"
95+
base.mkdir(parents=True, exist_ok=True)
96+
return base.resolve()
97+
98+
99+
# ---------------------------------------------------------------------------
100+
# Slugify / filename helpers
101+
# ---------------------------------------------------------------------------
102+
103+
104+
def slugify(text, max_length=60):
105+
"""Convert *text* to a filesystem-safe slug.
106+
107+
>>> slugify("Use temp files for JSON transfer!")
108+
'use-temp-files-for-json-transfer'
109+
"""
110+
text = text.lower()
111+
text = re.sub(r"[^a-z0-9]+", "-", text)
112+
text = text.strip("-")
113+
# Truncate at max_length, but don't break in the middle of a word
114+
if len(text) > max_length:
115+
text = text[:max_length].rsplit("-", 1)[0]
116+
return text or "entity"
117+
118+
119+
def unique_filename(directory, slug):
120+
"""Return a Path that doesn't collide with existing files in *directory*.
121+
122+
Tries ``slug.md``, then ``slug-2.md``, ``slug-3.md``, etc.
123+
"""
124+
directory = Path(directory)
125+
candidate = directory / f"{slug}.md"
126+
if not candidate.exists():
127+
return candidate
128+
n = 2
129+
while True:
130+
candidate = directory / f"{slug}-{n}.md"
131+
if not candidate.exists():
132+
return candidate
133+
n += 1
134+
135+
136+
# ---------------------------------------------------------------------------
137+
# Markdown <-> dict conversion
138+
# ---------------------------------------------------------------------------
139+
140+
_FRONTMATTER_KEYS = ("type", "trigger")
141+
142+
143+
def entity_to_markdown(entity):
144+
"""Serialize an entity dict to markdown with YAML frontmatter.
145+
146+
Args:
147+
entity: dict with keys ``content``, and optionally ``type``,
148+
``trigger``, ``rationale``.
149+
150+
Returns:
151+
A string suitable for writing to a ``.md`` file.
152+
"""
153+
lines = ["---"]
154+
for key in _FRONTMATTER_KEYS:
155+
val = entity.get(key)
156+
if val:
157+
lines.append(f"{key}: {val}")
158+
lines.append("---")
159+
lines.append("")
160+
161+
content = entity.get("content", "")
162+
lines.append(content)
163+
164+
rationale = entity.get("rationale")
165+
if rationale:
166+
lines.append("")
167+
lines.append("## Rationale")
168+
lines.append("")
169+
lines.append(rationale)
170+
171+
lines.append("")
172+
return "\n".join(lines)
173+
174+
175+
def markdown_to_entity(path):
176+
"""Parse a markdown entity file back into a dict.
177+
178+
Handles YAML frontmatter with simple ``key: value`` lines (no nested
179+
structures, no PyYAML dependency).
180+
181+
Returns:
182+
dict with ``content``, ``type``, ``trigger``, ``rationale`` keys.
183+
"""
184+
path = Path(path)
185+
text = path.read_text(encoding="utf-8")
186+
187+
entity = {}
188+
189+
# Split frontmatter
190+
if text.startswith("---"):
191+
parts = text.split("---", 2)
192+
if len(parts) >= 3:
193+
frontmatter = parts[1].strip()
194+
body = parts[2]
195+
for line in frontmatter.splitlines():
196+
line = line.strip()
197+
if not line:
198+
continue
199+
key, _, value = line.partition(":")
200+
key = key.strip()
201+
value = value.strip()
202+
if key and value:
203+
entity[key] = value
204+
else:
205+
body = text
206+
else:
207+
body = text
208+
209+
# Split body into content and rationale
210+
body = body.strip()
211+
m = re.search(r"^## Rationale", body, re.MULTILINE)
212+
if m:
213+
content = body[: m.start()].strip()
214+
rationale = body[m.end() :].strip()
215+
if rationale:
216+
entity["rationale"] = rationale
217+
else:
218+
content = body
219+
220+
if content:
221+
entity["content"] = content
222+
223+
return entity
224+
225+
226+
# ---------------------------------------------------------------------------
227+
# Bulk load / write
228+
# ---------------------------------------------------------------------------
229+
230+
231+
def load_all_entities(entities_dir):
232+
"""Glob ``**/*.md`` under *entities_dir* and parse each file.
233+
234+
Returns:
235+
list of entity dicts.
236+
"""
237+
entities_dir = Path(entities_dir)
238+
entities = []
239+
for md in sorted(entities_dir.glob("**/*.md")):
240+
try:
241+
entity = markdown_to_entity(md)
242+
if entity.get("content"):
243+
entities.append(entity)
244+
except OSError:
245+
pass
246+
return entities
247+
248+
249+
def write_entity_file(directory, entity):
250+
"""Write a single entity as a markdown file under *directory*.
251+
252+
The file is placed in a ``{type}/`` subdirectory. Uses atomic
253+
write (write to ``.tmp``, then ``os.rename``).
254+
255+
Returns:
256+
Path to the written file.
257+
"""
258+
entity_type = entity.get("type", "general")
259+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", entity_type):
260+
entity_type = "general"
261+
type_dir = Path(directory) / entity_type
262+
type_dir.mkdir(parents=True, exist_ok=True)
263+
264+
slug = slugify(entity.get("content", "entity"))
265+
content = entity_to_markdown(entity)
266+
267+
# Write to a unique temp file first (avoids predictable .tmp collisions)
268+
fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug)
269+
try:
270+
os.write(fd, content.encode("utf-8"))
271+
os.close(fd)
272+
fd = None
273+
274+
# Atomically claim the target using O_EXCL to detect races
275+
target = unique_filename(type_dir, slug)
276+
try:
277+
claim_fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
278+
os.close(claim_fd)
279+
except FileExistsError:
280+
# Another writer beat us — re-discover a free name
281+
target = unique_filename(type_dir, slug)
282+
283+
os.rename(tmp_path, target)
284+
return target
285+
except BaseException:
286+
if fd is not None:
287+
os.close(fd)
288+
if os.path.exists(tmp_path):
289+
os.unlink(tmp_path)
290+
raise

0 commit comments

Comments
 (0)