get-claudia 1.56.1 → 1.57.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/CHANGELOG.md +30 -0
- package/bin/index.js +4 -1
- package/package.json +1 -1
- package/template-v2/.claude/hooks/__pycache__/post-tool-capture.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/__pycache__/session-health-check.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/__pycache__/user-prompt-capture.cpython-313.pyc +0 -0
- package/template-v2/.claude/hooks/post-tool-capture.py +109 -9
- package/template-v2/.claude/hooks/session-health-check.py +52 -4
- package/template-v2/.claude/hooks/session-summary.py +399 -0
- package/template-v2/.claude/hooks/user-prompt-capture.py +123 -0
- package/template-v2/.claude/manifest.json +3 -2
- package/template-v2/.claude/rules/memory-commitment.md +92 -0
- package/template-v2/.claude/settings.local.json +26 -0
- package/template-v2/.claude/hooks/__pycache__/pre-compact.cpython-313.pyc +0 -0
- package/template-v2/gitignore +0 -35
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Claudia will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.57.0 (2026-05-13)
|
|
6
|
+
|
|
7
|
+
### The Curated Memory Release
|
|
8
|
+
|
|
9
|
+
Five PRs that complete one thesis: **curated, judgment-driven memory capture, enforced at prompt time and persisted across sessions.** Claudia now catches the user's intent when it matters, persists canonical facts as they emerge, and writes a daily session summary so context survives across days.
|
|
10
|
+
|
|
11
|
+
#### Fixed
|
|
12
|
+
- **PostToolUse hook actually runs (#38)** -- The hook was reading `os.environ.get("CLAUDE_TOOL_NAME")`, which Claude Code never sets. Every install since the hook landed had been silently no-op'ing, so `~/.claudia/observations.jsonl` was never written. The hook now reads its payload from stdin per the documented hook contract. Includes a sibling fix to the legacy `claudia/.claude/hooks/post-tool-capture.py` for codebase consistency.
|
|
13
|
+
|
|
14
|
+
#### Added
|
|
15
|
+
- **Memory-commitment rule (#39)** -- A new always-active rule (`template-v2/.claude/rules/memory-commitment.md`) codifies when to save canonical facts immediately via `memory_remember` / `memory_batch` rather than batching to end-of-session reflection. Trigger phrases include "lock this in," "remember this," "this is canonical." Substantive-artifact discipline: at the end of producing a multi-file artifact, do a memory commitment pass and save the canonical facts as one bundled `memory_batch` call.
|
|
16
|
+
- **UserPromptSubmit hook with intent detection (#42)** -- A new hook (`template-v2/.claude/hooks/user-prompt-capture.py`) inspects the user's prompt at submit time and injects reminder context for two trigger classes. Class 1: canonical-fact phrases ("lock this in," "remember this," etc.) tell the agent to save immediately rather than wait for `/meditate`. Class 2: destructive command patterns (`rm -rf`, `git push --force`, `DROP TABLE`, etc.) trigger a "verify before acting" reminder per the safety-first principle. Destructive patterns are surfaced to the model as human-readable labels (`rm -rf (recursive delete)`), not raw regex, so the agent can reason about them clearly.
|
|
17
|
+
- **Daily session summary system (#40)** -- A new SessionEnd hook (`template-v2/.claude/hooks/session-summary.py`) writes a per-session markdown summary to `~/.claudia/sessions/YYYY-MM-DD/NN-slug.md` covering opening prompt, files touched, external actions, and find-this-again references. SessionStart now surfaces a 3-day digest of recent sessions via the existing health-check hook, so future-Claudia knows what past-Claudia worked on. PostToolUse hook gained `file_path` extraction for Write/Edit/MultiEdit/NotebookEdit and `external_action` labels for git push, gh repo create, vercel/netlify deploy, supabase db push, and direct MCP sends.
|
|
18
|
+
- **Explicit upgrade messaging (#50)** -- The installer now names `~/.claudia/` explicitly after an upgrade and lists what is preserved (entities, relationships, reflections, embeddings) instead of the generic "data preserved" phrasing. Users care about their accumulated memory graph; the previous wording did not signal that the database is safe.
|
|
19
|
+
|
|
20
|
+
#### Changed
|
|
21
|
+
- **External-action detection uses word-boundary regex (#40)** -- Previously a substring match, so `echo "git push for testing"` falsely fired the `external_action` flag. The new patterns anchor on command separators (line start, `;`, `&&`, `|`, `(`) and skip transparent prefixes (`sudo`, `nohup`, `time`, `env`). False positives on echoed/quoted strings are eliminated; real commands still fire.
|
|
22
|
+
- **PostToolUse output truncation 200 -> 300 chars (#40)** -- Room for the richer output context that includes `file_path` and `external_action` labels alongside the truncated stdout/stderr.
|
|
23
|
+
|
|
24
|
+
#### Stats
|
|
25
|
+
- 41 new hook tests in `tests/hooks/` (stdlib `unittest`, zero new dependencies), all passing in ~1.5s
|
|
26
|
+
- TDD sensitivity proofs for every behavior change: tests fail on the un-modified hook, pass after the fix
|
|
27
|
+
- 5 PRs merged, 0 regressions
|
|
28
|
+
|
|
29
|
+
#### Notes
|
|
30
|
+
- The brief that drove this chain emphasized one principle: **trust the existing user-file preservation policy (commit `efce9f2`)** rather than inventing a new upgrade framework. The installer's behavior didn't change; only the messaging did.
|
|
31
|
+
- The four hook PRs each landed with their own automated tests and TDD sensitivity proofs. The legacy `claudia/` subdirectory was kept in sync with the canonical `template-v2/` to avoid maintenance drift.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
5
35
|
## 1.56.1 (2026-04-11)
|
|
6
36
|
|
|
7
37
|
### Preserve User-Modified Skills on Upgrade
|
package/bin/index.js
CHANGED
|
@@ -877,7 +877,10 @@ async function main() {
|
|
|
877
877
|
}
|
|
878
878
|
|
|
879
879
|
console.log('');
|
|
880
|
-
console.log(` ${colors.cyan}✓${colors.reset} Framework updated
|
|
880
|
+
console.log(` ${colors.cyan}✓${colors.reset} Framework updated`);
|
|
881
|
+
console.log(` • Your memory at ${colors.bold}~/.claudia/${colors.reset} is preserved (entities, relationships, reflections, embeddings).`);
|
|
882
|
+
console.log(` • Skills and hooks refreshed; any modifications you chose to keep were respected.`);
|
|
883
|
+
console.log(` • Restart Claude Code for changes to take effect.`);
|
|
881
884
|
}
|
|
882
885
|
|
|
883
886
|
// Self-heal: strip CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS from settings (#24)
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -1,37 +1,137 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""PostToolUse hook: passively captures tool invocations to a JSONL file.
|
|
3
3
|
|
|
4
|
+
Reads hook input from stdin (Claude Code's actual contract), not env vars.
|
|
4
5
|
Writes observations to ~/.claudia/observations.jsonl for later ingestion
|
|
5
|
-
by the memory daemon
|
|
6
|
+
by the memory daemon and by the session-summary generator.
|
|
7
|
+
|
|
8
|
+
Designed to complete in <50ms (file append only, no network).
|
|
9
|
+
|
|
10
|
+
Captured per-tool-call:
|
|
11
|
+
- tool name + truncated input/output
|
|
12
|
+
- file paths touched (for Write/Edit/MultiEdit/NotebookEdit)
|
|
13
|
+
- external actions (git push, gh repo create, vercel deploy, etc.)
|
|
14
|
+
- session_id for linking observations to a session
|
|
6
15
|
"""
|
|
7
16
|
|
|
8
17
|
import json
|
|
9
|
-
import
|
|
18
|
+
import re
|
|
10
19
|
import sys
|
|
11
20
|
import time
|
|
12
21
|
from pathlib import Path
|
|
13
22
|
|
|
23
|
+
# Skip noisy or read-only tools that don't need observation
|
|
14
24
|
SKIP_PREFIXES = ("memory_", "memory.", "mcp__plugin_episodic", "cognitive.")
|
|
15
|
-
SKIP_NAMES = {
|
|
25
|
+
SKIP_NAMES = {
|
|
26
|
+
"Read", "Glob", "Grep", "LS", "TodoRead",
|
|
27
|
+
"ListMcpResourcesTool", "ReadMcpResourceTool",
|
|
28
|
+
"TaskList", "TaskGet", "TaskOutput", "ToolSearch",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Bash command patterns that signal an "external action" worth flagging in
|
|
32
|
+
# the daily summary. Anchored so the candidate command must appear at the
|
|
33
|
+
# start of the line or after a shell separator (`;`, `&&`, `|`, `\n`, `(`),
|
|
34
|
+
# with optional transparent prefixes (`sudo`, `nohup`, `time`, `env VAR=`).
|
|
35
|
+
# This rejects echo'd test JSON ("git push for testing") and prefixed
|
|
36
|
+
# look-alikes ("git pushd", "ungit push") while still firing on real
|
|
37
|
+
# invocations including chained ones (`cd foo && git push`).
|
|
38
|
+
_BASH_CMD_ANCHOR = (
|
|
39
|
+
r"(?:^|[;&|\n(])\s*"
|
|
40
|
+
r"(?:sudo\s+|nohup\s+|time\s+|env\s+\w+=\S+\s+)*"
|
|
41
|
+
)
|
|
42
|
+
EXTERNAL_ACTION_PATTERNS: list[tuple[str, re.Pattern]] = [
|
|
43
|
+
("git push", re.compile(_BASH_CMD_ANCHOR + r"git\s+push\b")),
|
|
44
|
+
("gh repo create", re.compile(_BASH_CMD_ANCHOR + r"gh\s+repo\s+create\b")),
|
|
45
|
+
("gh pr create", re.compile(_BASH_CMD_ANCHOR + r"gh\s+pr\s+create\b")),
|
|
46
|
+
("gh release", re.compile(_BASH_CMD_ANCHOR + r"gh\s+release\b")),
|
|
47
|
+
("vercel --prod", re.compile(_BASH_CMD_ANCHOR + r"vercel\s+(?:[^\n]*\s)?--prod\b")),
|
|
48
|
+
("vercel deploy", re.compile(_BASH_CMD_ANCHOR + r"vercel\s+deploy\b")),
|
|
49
|
+
("netlify deploy", re.compile(_BASH_CMD_ANCHOR + r"netlify\s+deploy\b")),
|
|
50
|
+
("supabase db push", re.compile(_BASH_CMD_ANCHOR + r"supabase\s+db\s+push\b")),
|
|
51
|
+
("npx supabase", re.compile(_BASH_CMD_ANCHOR + r"npx\s+supabase\b")),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_external_action(tool_name: str, tool_input) -> str | None:
|
|
56
|
+
"""Return a short label if this tool call looks like an external action."""
|
|
57
|
+
if tool_name == "Bash":
|
|
58
|
+
cmd = (tool_input or {}).get("command", "") if tool_input else ""
|
|
59
|
+
if not cmd:
|
|
60
|
+
return None
|
|
61
|
+
for label, regex in EXTERNAL_ACTION_PATTERNS:
|
|
62
|
+
if regex.search(cmd):
|
|
63
|
+
return label
|
|
64
|
+
elif tool_name in {
|
|
65
|
+
"mcp__claudia-private-email__claudia_email_send",
|
|
66
|
+
"mcp__gmail__send_email", "mcp__gmail__draft_email",
|
|
67
|
+
}:
|
|
68
|
+
return "email send/draft"
|
|
69
|
+
elif tool_name in {
|
|
70
|
+
"mcp__google-calendar__create_event",
|
|
71
|
+
"mcp__google-calendar__update_event",
|
|
72
|
+
"mcp__google-calendar__delete_event",
|
|
73
|
+
}:
|
|
74
|
+
return "calendar event"
|
|
75
|
+
elif tool_name.startswith("mcp__claude_ai_Slack__slack_send"):
|
|
76
|
+
return "slack send"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_file_path(tool_name: str, tool_input: dict) -> str | None:
|
|
81
|
+
"""For Write/Edit/MultiEdit/NotebookEdit, return the file path being modified."""
|
|
82
|
+
if tool_name in {"Write", "Edit", "MultiEdit", "NotebookEdit"}:
|
|
83
|
+
return (tool_input or {}).get("file_path")
|
|
84
|
+
return None
|
|
16
85
|
|
|
17
86
|
|
|
18
87
|
def main():
|
|
19
|
-
|
|
88
|
+
# Claude Code passes hook payload as JSON on stdin.
|
|
89
|
+
try:
|
|
90
|
+
raw = sys.stdin.read()
|
|
91
|
+
if not raw.strip():
|
|
92
|
+
return
|
|
93
|
+
payload = json.loads(raw)
|
|
94
|
+
except (json.JSONDecodeError, OSError):
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
tool_name = payload.get("tool_name", "")
|
|
20
98
|
if not tool_name:
|
|
21
99
|
return
|
|
22
100
|
|
|
23
101
|
if any(tool_name.startswith(p) for p in SKIP_PREFIXES) or tool_name in SKIP_NAMES:
|
|
24
102
|
return
|
|
25
103
|
|
|
26
|
-
tool_input =
|
|
27
|
-
|
|
104
|
+
tool_input = payload.get("tool_input") or {}
|
|
105
|
+
tool_response = payload.get("tool_response") or {}
|
|
106
|
+
session_id = payload.get("session_id", "")
|
|
107
|
+
|
|
108
|
+
input_summary = json.dumps(tool_input)[:300] if tool_input else ""
|
|
109
|
+
output_summary = ""
|
|
110
|
+
if isinstance(tool_response, dict):
|
|
111
|
+
for key in ("stdout", "output", "result", "content"):
|
|
112
|
+
if key in tool_response:
|
|
113
|
+
val = tool_response[key]
|
|
114
|
+
output_summary = str(val)[:300] if val else ""
|
|
115
|
+
break
|
|
116
|
+
if not output_summary:
|
|
117
|
+
output_summary = json.dumps(tool_response)[:300]
|
|
118
|
+
else:
|
|
119
|
+
output_summary = str(tool_response)[:300]
|
|
120
|
+
|
|
121
|
+
file_path = extract_file_path(tool_name, tool_input)
|
|
122
|
+
external_action = is_external_action(tool_name, tool_input)
|
|
28
123
|
|
|
29
124
|
observation = {
|
|
30
|
-
"tool": tool_name,
|
|
31
|
-
"input": tool_input,
|
|
32
|
-
"output": tool_output,
|
|
33
125
|
"ts": time.time(),
|
|
126
|
+
"session_id": session_id,
|
|
127
|
+
"tool": tool_name,
|
|
128
|
+
"input": input_summary,
|
|
129
|
+
"output": output_summary,
|
|
34
130
|
}
|
|
131
|
+
if file_path:
|
|
132
|
+
observation["file_path"] = file_path
|
|
133
|
+
if external_action:
|
|
134
|
+
observation["external_action"] = external_action
|
|
35
135
|
|
|
36
136
|
obs_file = Path.home() / ".claudia" / "observations.jsonl"
|
|
37
137
|
obs_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -24,6 +24,50 @@ def _fetch_briefing():
|
|
|
24
24
|
return None
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _recent_sessions_summary(max_days: int = 3) -> str:
|
|
28
|
+
"""Return a compact recap of the last N days of session summaries.
|
|
29
|
+
|
|
30
|
+
Reads ~/.claudia/sessions/YYYY-MM-DD/INDEX.md files. Returns a short
|
|
31
|
+
multi-day digest showing what was worked on. Bounded to keep startup fast.
|
|
32
|
+
|
|
33
|
+
Empty string when no session history exists yet (fresh installs).
|
|
34
|
+
"""
|
|
35
|
+
sessions_dir = Path.home() / ".claudia" / "sessions"
|
|
36
|
+
if not sessions_dir.exists():
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
date_folders = sorted(
|
|
40
|
+
[d for d in sessions_dir.iterdir() if d.is_dir() and d.name[:4].isdigit()],
|
|
41
|
+
reverse=True,
|
|
42
|
+
)[:max_days]
|
|
43
|
+
|
|
44
|
+
if not date_folders:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
lines = []
|
|
48
|
+
for date_dir in date_folders:
|
|
49
|
+
summaries = sorted(date_dir.glob("[0-9][0-9]-*.md"))
|
|
50
|
+
if not summaries:
|
|
51
|
+
continue
|
|
52
|
+
topics = []
|
|
53
|
+
for f in summaries:
|
|
54
|
+
try:
|
|
55
|
+
first_line = f.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
56
|
+
topic = first_line.lstrip("#").strip()
|
|
57
|
+
if "—" in topic:
|
|
58
|
+
topic = topic.split("—", 1)[1].strip()
|
|
59
|
+
topics.append(topic)
|
|
60
|
+
except OSError:
|
|
61
|
+
continue
|
|
62
|
+
if topics:
|
|
63
|
+
lines.append(f" {date_dir.name}: {' · '.join(topics[:5])}")
|
|
64
|
+
|
|
65
|
+
if not lines:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
return "Recent sessions (from ~/.claudia/sessions/):\n" + "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
27
71
|
def _run_claudia(*args):
|
|
28
72
|
"""Run claudia CLI and return parsed JSON output, or None on failure."""
|
|
29
73
|
claudia_bin = shutil.which("claudia")
|
|
@@ -65,11 +109,15 @@ def check_health():
|
|
|
65
109
|
|
|
66
110
|
summary = " ".join(parts) if parts else "System ready."
|
|
67
111
|
briefing = _fetch_briefing()
|
|
112
|
+
recent = _recent_sessions_summary()
|
|
113
|
+
|
|
114
|
+
sections = [f"Memory system healthy. {summary}"]
|
|
115
|
+
if recent:
|
|
116
|
+
sections.append("--- Recent Sessions ---\n" + recent)
|
|
68
117
|
if briefing:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
print(json.dumps({"additionalContext": output}))
|
|
118
|
+
sections.append("--- Session Briefing ---\n" + briefing)
|
|
119
|
+
|
|
120
|
+
print(json.dumps({"additionalContext": "\n\n".join(sections)}))
|
|
73
121
|
return
|
|
74
122
|
|
|
75
123
|
# CLI not available -- provide fallback guidance
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a daily session summary markdown file from observations.jsonl.
|
|
3
|
+
|
|
4
|
+
Designed to run from a SessionEnd hook OR manually for retrospective summaries.
|
|
5
|
+
|
|
6
|
+
Output path: ~/.claudia/sessions/YYYY-MM-DD/NN-slug.md
|
|
7
|
+
Plus an INDEX.md per day, auto-regenerated.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
session-summary.py # uses CLAUDE_SESSION_ID env var or stdin JSON
|
|
11
|
+
session-summary.py <session_id> # explicit session id
|
|
12
|
+
session-summary.py --rebuild-index # regenerate today's INDEX.md only
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
OBS_FILE = Path.home() / ".claudia" / "observations.jsonl"
|
|
25
|
+
SESSIONS_DIR = Path.home() / ".claudia" / "sessions"
|
|
26
|
+
|
|
27
|
+
# Words to filter out when deriving topic slugs
|
|
28
|
+
STOPWORDS = {
|
|
29
|
+
"the", "a", "an", "and", "or", "but", "if", "of", "to", "for", "with",
|
|
30
|
+
"in", "on", "at", "by", "is", "are", "was", "were", "be", "been", "being",
|
|
31
|
+
"i", "you", "we", "us", "they", "this", "that", "these", "those",
|
|
32
|
+
"have", "has", "had", "do", "does", "did", "can", "could", "will",
|
|
33
|
+
"would", "should", "may", "might", "must", "let", "let's", "ok", "okay",
|
|
34
|
+
"yes", "no", "now", "go", "ahead", "please", "thanks", "today",
|
|
35
|
+
"use", "make", "get", "see", "look", "want",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_observations(session_id: str = None) -> list[dict]:
|
|
40
|
+
"""Load observations from the JSONL file, optionally filtered by session_id."""
|
|
41
|
+
if not OBS_FILE.exists():
|
|
42
|
+
return []
|
|
43
|
+
obs = []
|
|
44
|
+
try:
|
|
45
|
+
with open(OBS_FILE, "r", encoding="utf-8") as f:
|
|
46
|
+
for line in f:
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
o = json.loads(line)
|
|
52
|
+
if session_id and o.get("session_id") != session_id:
|
|
53
|
+
continue
|
|
54
|
+
obs.append(o)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
except OSError:
|
|
58
|
+
return []
|
|
59
|
+
return obs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def first_user_prompt(transcript_path: str) -> str | None:
|
|
63
|
+
"""Try to extract the first user prompt from a transcript file (JSONL of turns)."""
|
|
64
|
+
if not transcript_path or not Path(transcript_path).exists():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
with open(transcript_path, "r", encoding="utf-8") as f:
|
|
68
|
+
for line in f:
|
|
69
|
+
try:
|
|
70
|
+
turn = json.loads(line)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
role = turn.get("role") or turn.get("type")
|
|
74
|
+
if role == "user":
|
|
75
|
+
content = turn.get("content") or turn.get("text") or ""
|
|
76
|
+
if isinstance(content, list):
|
|
77
|
+
for block in content:
|
|
78
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
79
|
+
return block.get("text", "")[:500]
|
|
80
|
+
elif isinstance(content, str):
|
|
81
|
+
return content[:500]
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def derive_topic_slug(observations: list[dict], first_prompt: str | None) -> str:
|
|
88
|
+
"""Derive a 2-4 word topic slug from observations and first prompt."""
|
|
89
|
+
text_pool = []
|
|
90
|
+
|
|
91
|
+
if first_prompt:
|
|
92
|
+
text_pool.append(first_prompt)
|
|
93
|
+
|
|
94
|
+
for o in observations:
|
|
95
|
+
if o.get("file_path"):
|
|
96
|
+
parts = Path(o["file_path"]).parts
|
|
97
|
+
if len(parts) >= 2:
|
|
98
|
+
text_pool.append(parts[-2])
|
|
99
|
+
|
|
100
|
+
text_blob = " ".join(text_pool).lower()
|
|
101
|
+
words = re.findall(r"[a-z]{3,}", text_blob)
|
|
102
|
+
words = [w for w in words if w not in STOPWORDS]
|
|
103
|
+
|
|
104
|
+
if not words:
|
|
105
|
+
return "session"
|
|
106
|
+
|
|
107
|
+
counter = Counter(words)
|
|
108
|
+
top_words = [w for w, _ in counter.most_common(4)]
|
|
109
|
+
slug = "-".join(top_words[:3]) if top_words else "session"
|
|
110
|
+
return slug[:60]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def session_window(observations: list[dict]) -> tuple[float, float]:
|
|
114
|
+
"""Return (start_ts, end_ts) from observation timestamps."""
|
|
115
|
+
if not observations:
|
|
116
|
+
return (time.time(), time.time())
|
|
117
|
+
timestamps = [o.get("ts", 0) for o in observations if o.get("ts")]
|
|
118
|
+
if not timestamps:
|
|
119
|
+
return (time.time(), time.time())
|
|
120
|
+
return (min(timestamps), max(timestamps))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def files_touched(observations: list[dict]) -> list[str]:
|
|
124
|
+
"""Return unique file paths touched in this session, in order of first appearance."""
|
|
125
|
+
seen = []
|
|
126
|
+
seen_set = set()
|
|
127
|
+
for o in observations:
|
|
128
|
+
fp = o.get("file_path")
|
|
129
|
+
if fp and fp not in seen_set:
|
|
130
|
+
seen.append(fp)
|
|
131
|
+
seen_set.add(fp)
|
|
132
|
+
return seen
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def external_actions(observations: list[dict]) -> list[dict]:
|
|
136
|
+
"""Return observations flagged with external_action."""
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
"ts": o.get("ts"),
|
|
140
|
+
"tool": o.get("tool"),
|
|
141
|
+
"action": o.get("external_action"),
|
|
142
|
+
"input": o.get("input", "")[:200],
|
|
143
|
+
}
|
|
144
|
+
for o in observations if o.get("external_action")
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def memory_entries_in_window(start_ts: float, end_ts: float) -> list[dict]:
|
|
149
|
+
"""Best-effort fetch of memory entries created during the session window.
|
|
150
|
+
|
|
151
|
+
Relies on the claudia-memory daemon's HTTP endpoint if available.
|
|
152
|
+
Returns empty list if not reachable. The daemon does not currently
|
|
153
|
+
expose a /recent_memories endpoint; this is a placeholder for a
|
|
154
|
+
future enhancement.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
import urllib.request
|
|
158
|
+
url = f"http://localhost:3848/recent_memories?since={start_ts}&until={end_ts}"
|
|
159
|
+
req = urllib.request.Request(url)
|
|
160
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
161
|
+
return json.loads(resp.read().decode("utf-8")).get("memories", [])
|
|
162
|
+
except Exception:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def next_session_number(date_dir: Path) -> int:
|
|
167
|
+
"""Return the next sequential session number for the day."""
|
|
168
|
+
if not date_dir.exists():
|
|
169
|
+
return 1
|
|
170
|
+
existing = []
|
|
171
|
+
for f in date_dir.glob("[0-9][0-9]-*.md"):
|
|
172
|
+
match = re.match(r"^(\d{2})-", f.name)
|
|
173
|
+
if match:
|
|
174
|
+
existing.append(int(match.group(1)))
|
|
175
|
+
return (max(existing) + 1) if existing else 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_summary(
|
|
179
|
+
session_id: str,
|
|
180
|
+
date_str: str,
|
|
181
|
+
session_num: int,
|
|
182
|
+
topic_slug: str,
|
|
183
|
+
start_ts: float,
|
|
184
|
+
end_ts: float,
|
|
185
|
+
files: list[str],
|
|
186
|
+
actions: list[dict],
|
|
187
|
+
memories: list[dict],
|
|
188
|
+
first_prompt: str | None,
|
|
189
|
+
transcript_path: str | None,
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Render the session summary markdown."""
|
|
192
|
+
duration_min = max(1, round((end_ts - start_ts) / 60))
|
|
193
|
+
started = datetime.fromtimestamp(start_ts, tz=timezone.utc).astimezone()
|
|
194
|
+
ended = datetime.fromtimestamp(end_ts, tz=timezone.utc).astimezone()
|
|
195
|
+
|
|
196
|
+
lines = [
|
|
197
|
+
f"# Session {session_num:02d} — {topic_slug.replace('-', ' ').title()}",
|
|
198
|
+
"",
|
|
199
|
+
f"**Date:** {date_str}",
|
|
200
|
+
f"**Started:** {started.strftime('%H:%M %Z')}",
|
|
201
|
+
f"**Ended:** {ended.strftime('%H:%M %Z')}",
|
|
202
|
+
f"**Duration:** ~{duration_min} min",
|
|
203
|
+
f"**Session ID:** `{session_id}`",
|
|
204
|
+
"",
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if first_prompt:
|
|
208
|
+
lines += [
|
|
209
|
+
"## Opening prompt",
|
|
210
|
+
"",
|
|
211
|
+
"> " + first_prompt.strip().split("\n")[0][:300],
|
|
212
|
+
"",
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
lines += ["## Files touched", ""]
|
|
216
|
+
if files:
|
|
217
|
+
for f in files:
|
|
218
|
+
lines.append(f"- `{f}`")
|
|
219
|
+
else:
|
|
220
|
+
lines.append("- (none)")
|
|
221
|
+
lines.append("")
|
|
222
|
+
|
|
223
|
+
lines += ["## External actions", ""]
|
|
224
|
+
if actions:
|
|
225
|
+
for a in actions:
|
|
226
|
+
lines.append(f"- **{a.get('action')}** via `{a.get('tool')}` — `{a.get('input')[:120]}`")
|
|
227
|
+
else:
|
|
228
|
+
lines.append("- (none)")
|
|
229
|
+
lines.append("")
|
|
230
|
+
|
|
231
|
+
lines += ["## Memory entries created", ""]
|
|
232
|
+
if memories:
|
|
233
|
+
for m in memories[:30]:
|
|
234
|
+
mid = m.get("id") or m.get("memory_id") or "?"
|
|
235
|
+
content = (m.get("content") or "")[:200]
|
|
236
|
+
lines.append(f"- `mem-{mid}` — {content}")
|
|
237
|
+
else:
|
|
238
|
+
lines.append("- (none captured — memory daemon may not expose recent_memories endpoint)")
|
|
239
|
+
lines.append("")
|
|
240
|
+
|
|
241
|
+
if transcript_path:
|
|
242
|
+
lines += [
|
|
243
|
+
"## Find this again",
|
|
244
|
+
"",
|
|
245
|
+
f"- Transcript: `{transcript_path}`",
|
|
246
|
+
f"- Memory query: `\"{topic_slug.replace('-', ' ')}\"`",
|
|
247
|
+
"",
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
lines += [
|
|
251
|
+
"---",
|
|
252
|
+
f"*Auto-generated by session-summary.py at {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')}*",
|
|
253
|
+
"",
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def regenerate_index(date_dir: Path) -> None:
|
|
260
|
+
"""Regenerate INDEX.md for a given day's folder."""
|
|
261
|
+
if not date_dir.exists():
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
sessions = []
|
|
265
|
+
for f in sorted(date_dir.glob("[0-9][0-9]-*.md")):
|
|
266
|
+
first_lines = f.read_text(encoding="utf-8").split("\n")[:6]
|
|
267
|
+
title = first_lines[0].lstrip("# ").strip() if first_lines else f.name
|
|
268
|
+
started = ""
|
|
269
|
+
for line in first_lines:
|
|
270
|
+
if line.startswith("**Started:**"):
|
|
271
|
+
started = line.replace("**Started:**", "").strip()
|
|
272
|
+
break
|
|
273
|
+
sessions.append((f.name, title, started))
|
|
274
|
+
|
|
275
|
+
date_str = date_dir.name
|
|
276
|
+
lines = [
|
|
277
|
+
f"# Sessions — {date_str}",
|
|
278
|
+
"",
|
|
279
|
+
f"*{len(sessions)} session(s) captured.*",
|
|
280
|
+
"",
|
|
281
|
+
"| # | Topic | Started | File |",
|
|
282
|
+
"|---|-------|---------|------|",
|
|
283
|
+
]
|
|
284
|
+
for fname, title, started in sessions:
|
|
285
|
+
match = re.match(r"^(\d{2})-", fname)
|
|
286
|
+
num = match.group(1) if match else "??"
|
|
287
|
+
clean_title = title.split("—", 1)[1].strip() if "—" in title else title
|
|
288
|
+
lines.append(f"| {num} | {clean_title} | {started} | [`{fname}`](./{fname}) |")
|
|
289
|
+
lines += [
|
|
290
|
+
"",
|
|
291
|
+
f"*INDEX regenerated {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')}*",
|
|
292
|
+
"",
|
|
293
|
+
]
|
|
294
|
+
(date_dir / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main():
|
|
298
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--rebuild-index":
|
|
299
|
+
date_str = sys.argv[2] if len(sys.argv) > 2 else datetime.now().astimezone().strftime("%Y-%m-%d")
|
|
300
|
+
regenerate_index(SESSIONS_DIR / date_str)
|
|
301
|
+
print(json.dumps({"action": "rebuild-index", "date": date_str}))
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
session_id = ""
|
|
305
|
+
transcript_path = ""
|
|
306
|
+
|
|
307
|
+
if len(sys.argv) > 1:
|
|
308
|
+
session_id = sys.argv[1]
|
|
309
|
+
else:
|
|
310
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
311
|
+
# Try stdin (SessionEnd hook contract)
|
|
312
|
+
if not session_id and not sys.stdin.isatty():
|
|
313
|
+
try:
|
|
314
|
+
raw = sys.stdin.read()
|
|
315
|
+
if raw.strip():
|
|
316
|
+
payload = json.loads(raw)
|
|
317
|
+
session_id = payload.get("session_id", "")
|
|
318
|
+
transcript_path = payload.get("transcript_path", "")
|
|
319
|
+
except (json.JSONDecodeError, OSError):
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
if not session_id:
|
|
323
|
+
print(json.dumps({"error": "no session_id provided"}))
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
observations = load_observations(session_id)
|
|
327
|
+
if not observations:
|
|
328
|
+
print(json.dumps({"warning": "no observations for session", "session_id": session_id}))
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
start_ts, end_ts = session_window(observations)
|
|
332
|
+
date_str = datetime.fromtimestamp(start_ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d")
|
|
333
|
+
date_dir = SESSIONS_DIR / date_str
|
|
334
|
+
date_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
|
|
336
|
+
first_prompt = first_user_prompt(transcript_path) if transcript_path else None
|
|
337
|
+
topic_slug = derive_topic_slug(observations, first_prompt)
|
|
338
|
+
|
|
339
|
+
# Check if a summary for this session already exists; overwrite with latest data
|
|
340
|
+
existing_file = None
|
|
341
|
+
session_num = next_session_number(date_dir)
|
|
342
|
+
for existing in date_dir.glob("[0-9][0-9]-*.md"):
|
|
343
|
+
try:
|
|
344
|
+
content = existing.read_text(encoding="utf-8")
|
|
345
|
+
if f"`{session_id}`" in content:
|
|
346
|
+
existing_file = existing
|
|
347
|
+
match = re.match(r"^(\d{2})-", existing.name)
|
|
348
|
+
if match:
|
|
349
|
+
session_num = int(match.group(1))
|
|
350
|
+
break
|
|
351
|
+
except OSError:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
files = files_touched(observations)
|
|
355
|
+
actions = external_actions(observations)
|
|
356
|
+
memories = memory_entries_in_window(start_ts, end_ts)
|
|
357
|
+
|
|
358
|
+
summary = render_summary(
|
|
359
|
+
session_id=session_id,
|
|
360
|
+
date_str=date_str,
|
|
361
|
+
session_num=session_num,
|
|
362
|
+
topic_slug=topic_slug,
|
|
363
|
+
start_ts=start_ts,
|
|
364
|
+
end_ts=end_ts,
|
|
365
|
+
files=files,
|
|
366
|
+
actions=actions,
|
|
367
|
+
memories=memories,
|
|
368
|
+
first_prompt=first_prompt,
|
|
369
|
+
transcript_path=transcript_path,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
out_file = date_dir / f"{session_num:02d}-{topic_slug}.md"
|
|
373
|
+
|
|
374
|
+
# If existing file had a different topic slug, remove the stale name
|
|
375
|
+
if existing_file and existing_file != out_file:
|
|
376
|
+
try:
|
|
377
|
+
existing_file.unlink()
|
|
378
|
+
except OSError:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
out_file.write_text(summary, encoding="utf-8")
|
|
382
|
+
regenerate_index(date_dir)
|
|
383
|
+
|
|
384
|
+
print(json.dumps({
|
|
385
|
+
"ok": True,
|
|
386
|
+
"session_id": session_id,
|
|
387
|
+
"file": str(out_file),
|
|
388
|
+
"files_touched": len(files),
|
|
389
|
+
"external_actions": len(actions),
|
|
390
|
+
"memories_in_window": len(memories),
|
|
391
|
+
"updated": existing_file is not None,
|
|
392
|
+
}))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
try:
|
|
397
|
+
main()
|
|
398
|
+
except Exception as e:
|
|
399
|
+
print(json.dumps({"error": str(e)[:200]}))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook: detect canonical-fact trigger phrases and destructive
|
|
3
|
+
verbs in user prompts, inject reminder context for the agent.
|
|
4
|
+
|
|
5
|
+
Reads hook payload from stdin (Claude Code hook contract).
|
|
6
|
+
Outputs JSON with additionalContext when triggers fire. Outputs nothing otherwise.
|
|
7
|
+
|
|
8
|
+
Designed to complete in <5ms (regex match + simple JSON output, no network).
|
|
9
|
+
|
|
10
|
+
Two trigger classes:
|
|
11
|
+
1. Memory-commitment phrases ("lock this in", "remember this", "this is canonical")
|
|
12
|
+
-> Inject reminder for the agent to save the fact to memory immediately,
|
|
13
|
+
per the memory-commitment rule (which governs tool selection).
|
|
14
|
+
2. Destructive operation patterns (rm -rf, drop table, force push, etc.)
|
|
15
|
+
-> Inject "verify before acting" reminder per Claudia's safety-first principle.
|
|
16
|
+
|
|
17
|
+
Both classes can fire on the same prompt; both messages are concatenated.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# Trigger phrases that signal the user is asserting a canonical fact.
|
|
25
|
+
# Word boundaries (\b) used to avoid matching inside other words.
|
|
26
|
+
COMMITMENT_TRIGGERS = [
|
|
27
|
+
r"\block this in\b",
|
|
28
|
+
r"\bremember this\b",
|
|
29
|
+
r"\bthis is canonical\b",
|
|
30
|
+
r"\bthis is locked\b",
|
|
31
|
+
r"\bsave this for later\b",
|
|
32
|
+
r"\bimportant to remember\b",
|
|
33
|
+
r"\bfor the record\b",
|
|
34
|
+
r"\bdon'?t forget\b",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Destructive operation patterns. Narrow matches to limit false positives.
|
|
38
|
+
# Conversation about deletion ("how do I undo a delete?") should NOT fire these.
|
|
39
|
+
DESTRUCTIVE_PATTERNS = [
|
|
40
|
+
r"\brm\s+-rf\b",
|
|
41
|
+
r"\bdrop\s+(table|database|schema)\b",
|
|
42
|
+
r"\bgit\s+push\s+(?:-+f\b|--force\b)",
|
|
43
|
+
r"\bgit\s+reset\s+--hard\b",
|
|
44
|
+
r"\btruncate\s+table\b",
|
|
45
|
+
r"\bDELETE\s+FROM\b", # SQL all-caps signals intent
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Human-readable labels for each destructive pattern. Keep keys in lockstep
|
|
49
|
+
# with DESTRUCTIVE_PATTERNS so the model never sees raw regex syntax.
|
|
50
|
+
PATTERN_LABELS = {
|
|
51
|
+
r"\brm\s+-rf\b": "rm -rf (recursive delete)",
|
|
52
|
+
r"\bdrop\s+(table|database|schema)\b": "DROP TABLE/DATABASE/SCHEMA",
|
|
53
|
+
r"\bgit\s+push\s+(?:-+f\b|--force\b)": "git push --force",
|
|
54
|
+
r"\bgit\s+reset\s+--hard\b": "git reset --hard",
|
|
55
|
+
r"\btruncate\s+table\b": "TRUNCATE TABLE",
|
|
56
|
+
r"\bDELETE\s+FROM\b": "DELETE FROM (SQL)",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Fail-fast at import time if a pattern is missing a label (or vice versa).
|
|
60
|
+
assert set(DESTRUCTIVE_PATTERNS) == set(PATTERN_LABELS), (
|
|
61
|
+
"Every DESTRUCTIVE_PATTERN must have a matching PATTERN_LABELS entry"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect(text: str, patterns: list) -> list:
|
|
66
|
+
"""Return list of pattern strings that matched in the text."""
|
|
67
|
+
return [p for p in patterns if re.search(p, text, re.IGNORECASE)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
try:
|
|
72
|
+
raw = sys.stdin.read()
|
|
73
|
+
if not raw.strip():
|
|
74
|
+
return
|
|
75
|
+
payload = json.loads(raw)
|
|
76
|
+
except (json.JSONDecodeError, OSError):
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Claude Code passes the user's prompt under "prompt" (newer) or "text" (older).
|
|
80
|
+
prompt = payload.get("prompt") or payload.get("text") or ""
|
|
81
|
+
if not prompt:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
commitment_hits = detect(prompt, COMMITMENT_TRIGGERS)
|
|
85
|
+
destructive_hits = detect(prompt, DESTRUCTIVE_PATTERNS)
|
|
86
|
+
|
|
87
|
+
if not commitment_hits and not destructive_hits:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
sections = []
|
|
91
|
+
|
|
92
|
+
if commitment_hits:
|
|
93
|
+
sections.append(
|
|
94
|
+
"**Canonical-fact trigger detected.** Per the memory-commitment rule, "
|
|
95
|
+
"save the canonical fact to memory immediately. Do not batch to "
|
|
96
|
+
"/meditate. After saving, continue the conversation normally and "
|
|
97
|
+
"briefly surface that the fact has been recorded so the user knows "
|
|
98
|
+
"it is recoverable in future sessions."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if destructive_hits:
|
|
102
|
+
# Show up to three matched patterns for context, using human-readable
|
|
103
|
+
# labels so raw regex syntax never reaches the model.
|
|
104
|
+
labels_list = ", ".join(
|
|
105
|
+
f"`{PATTERN_LABELS[p]}`" for p in destructive_hits[:3]
|
|
106
|
+
)
|
|
107
|
+
sections.append(
|
|
108
|
+
f"**Destructive operation pattern detected** ({labels_list}). "
|
|
109
|
+
"Per the safety-first principle, verify with the user before executing: "
|
|
110
|
+
"show what will happen (recipients, content, irreversible effects), ask "
|
|
111
|
+
"for explicit confirmation, then proceed only on a clear yes. Silence or "
|
|
112
|
+
"ambiguity means do not proceed."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
output = {"additionalContext": "\n\n".join(sections)}
|
|
116
|
+
print(json.dumps(output))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
try:
|
|
121
|
+
main()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass # Never block Claude
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated": "2026-
|
|
2
|
+
"version": "1.57.0",
|
|
3
|
+
"generated": "2026-05-13T09:56:45.377Z",
|
|
4
4
|
"algorithm": "sha256",
|
|
5
5
|
"files": {
|
|
6
6
|
".claude/rules/claudia-principles.md": "b1c5e33aeb33857f3485231e5ed34a441c3cc4568b69e3341be770963418a240",
|
|
7
7
|
".claude/rules/data-freshness.md": "052b3b8f3f489a54fff065b29e0ffc46bfad6da1c3a42386170034f298599233",
|
|
8
8
|
".claude/rules/memory-availability.md": "48309e2683b267c0a17ebf019fb3ee1116150d811b1d93b4ddb52fed89ae1fe3",
|
|
9
|
+
".claude/rules/memory-commitment.md": "49eee330b56c6ca0b5f1e01550931c4eef3dcb3249e3d0e2380de3e8dbfe31a8",
|
|
9
10
|
".claude/rules/shell-compatibility.md": "565977bc04e269b3ce7d8a7963173df4f44bb9634692ea76abb1b64f6a67513e",
|
|
10
11
|
".claude/rules/trust-north-star.md": "0188b17c26b791cf597ce975bb75d40f543aa3d6b84b7edb1309b78530b3d43f",
|
|
11
12
|
".claude/skills/README.md": "f84c553ff5bc01f7a9a479b7358dbd16a60ee2ba11e01467fe1ed3d91e33e68e",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Memory Commitment
|
|
2
|
+
|
|
3
|
+
This rule is always active. Follow it silently. Do not cite this file by name in conversation.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The principle
|
|
8
|
+
|
|
9
|
+
**Save canonical facts to memory IMMEDIATELY when they emerge. Do not batch to /meditate.**
|
|
10
|
+
|
|
11
|
+
The memory database is what makes information recoverable across sessions. Artifacts on disk (markdown files, GitHub repos, PDFs) are not searchable from future sessions unless I happen to remember the path. Memory entries with proper entity links surface through both semantic search and entity browsing.
|
|
12
|
+
|
|
13
|
+
If a fact lives only in a file, it does not exist for future-me.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Save now when ANY of these happen
|
|
18
|
+
|
|
19
|
+
1. **User states a canonical fact** that is unlikely to change: hex code, URL, EIN, address, password location, locked decision, version number, identifier, credential location.
|
|
20
|
+
|
|
21
|
+
2. **User shares substantive source material** (>500 words, a transcript, a document, a brief, a prompt, a strategy doc). File it via `memory_file` BEFORE extracting from it.
|
|
22
|
+
|
|
23
|
+
3. **User overrides or corrects a stored memory or preference.** The correction is more important than the original. Save it with high importance and reference what it supersedes.
|
|
24
|
+
|
|
25
|
+
4. **A new project, repo, entity, integration, credential, or tool is created** during the session. Create the entity, then attach facts about it.
|
|
26
|
+
|
|
27
|
+
5. **User uses a trigger phrase**: "lock this in," "remember this," "this is canonical," "this is locked," "save this for later," "important to remember," "for the record," "don't forget."
|
|
28
|
+
|
|
29
|
+
6. **A judgment-relevant decision is made** (priorities, escalations, overrides, surfacing rules, delegation preferences). These also feed `context/judgment.yaml` via /meditate, but the fact itself goes into memory immediately.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## The test
|
|
34
|
+
|
|
35
|
+
Before deciding "I'll save this later," ask:
|
|
36
|
+
|
|
37
|
+
> **If I came back tomorrow with no transcript, would I need this fact to do good work?**
|
|
38
|
+
|
|
39
|
+
If yes, save it now. The test is meant to be cheap to apply: when in doubt, save.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## How to save
|
|
44
|
+
|
|
45
|
+
| Need | Tool | Use when |
|
|
46
|
+
|------|------|----------|
|
|
47
|
+
| One fact | `memory_remember` | Single fact emerges in conversation |
|
|
48
|
+
| Bundled save (entity + facts + relationships) | `memory_batch` | Processing a substantive artifact, transcript, document, or multi-fact moment |
|
|
49
|
+
| Raw source material before extraction | `memory_file` | User shares a document, transcript, email, brief |
|
|
50
|
+
| New relationship between entities | `memory_relate` | Two existing entities connect in a new way |
|
|
51
|
+
| Verify or build on prior memory | `memory_recall` / `memory_about` | Before saving, check if a related memory exists to update instead of duplicate |
|
|
52
|
+
|
|
53
|
+
**Prefer `memory_batch` over multiple `memory_remember` calls.** One round-trip handles entity creation, fact-saves, and relationships together. Faster, cleaner, less likely to be skipped.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## What NOT to save
|
|
58
|
+
|
|
59
|
+
Per the data-freshness rule:
|
|
60
|
+
|
|
61
|
+
- Volatile counts, statuses, progress numbers ("13K subscribers," "9 interviews completed," "94% stall rate")
|
|
62
|
+
- Dated state snapshots that can be re-derived from source files
|
|
63
|
+
- Anything that should live in a context file or canonical source instead
|
|
64
|
+
- Information already documented in CLAUDE.md or auto-memory MEMORY.md
|
|
65
|
+
|
|
66
|
+
When you encounter a useful but volatile fact, save a **pointer** to where the canonical source lives, not the value itself. Example: instead of "subscriber count is 13,000," save "subscriber count lives on the live homepage; check there for current."
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Substantive-artifact discipline
|
|
71
|
+
|
|
72
|
+
When producing a substantive artifact (brand bible, multi-doc plan, comprehensive analysis, custom skill, deployed integration), end the artifact-production block with a memory commitment pass:
|
|
73
|
+
|
|
74
|
+
1. List the canonical facts the artifact embodies.
|
|
75
|
+
2. Call `memory_batch` to save them as one bundle, with proper entity links and source context referencing the artifact location.
|
|
76
|
+
3. Mark the highest-leverage fact as `critical: true` only when it's a personal-identity-class fact (life motto, ethical lock, security-relevant rule).
|
|
77
|
+
|
|
78
|
+
The artifact lives on disk. The facts must live in memory too.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Why this rule exists
|
|
83
|
+
|
|
84
|
+
A common failure mode: an agent produces a multi-file artifact (brand bible, integration setup, comprehensive plan) over the course of a session. The artifact contains canonical facts (color palettes, credentials, decisions, URLs). End-of-session reflection captures only high-level reflections, not the specific facts. Days later, when those facts are needed again, they exist only on disk and the agent has no way to surface them through memory queries.
|
|
85
|
+
|
|
86
|
+
Result: the same facts get re-elicited, re-decided, re-committed. The memory system was the substrate, but it was treated as the afterthought.
|
|
87
|
+
|
|
88
|
+
This rule prevents that pattern.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
*Memory is the substrate, not the afterthought. Save as you go.*
|
|
@@ -39,6 +39,32 @@
|
|
|
39
39
|
}
|
|
40
40
|
]
|
|
41
41
|
}
|
|
42
|
+
],
|
|
43
|
+
"UserPromptSubmit": [
|
|
44
|
+
{
|
|
45
|
+
"matcher": "",
|
|
46
|
+
"hooks": [
|
|
47
|
+
{
|
|
48
|
+
"type": "command",
|
|
49
|
+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-capture.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-capture.py\" 2>/dev/null || true",
|
|
50
|
+
"timeout": 3000,
|
|
51
|
+
"statusMessage": ""
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"SessionEnd": [
|
|
57
|
+
{
|
|
58
|
+
"matcher": "",
|
|
59
|
+
"hooks": [
|
|
60
|
+
{
|
|
61
|
+
"type": "command",
|
|
62
|
+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || true",
|
|
63
|
+
"timeout": 10000,
|
|
64
|
+
"statusMessage": "Generating daily session summary..."
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
42
68
|
]
|
|
43
69
|
}
|
|
44
70
|
}
|
|
Binary file
|
package/template-v2/gitignore
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# Dependencies
|
|
2
|
-
node_modules/
|
|
3
|
-
|
|
4
|
-
# OS files
|
|
5
|
-
.DS_Store
|
|
6
|
-
Thumbs.db
|
|
7
|
-
|
|
8
|
-
# Editor files
|
|
9
|
-
*.swp
|
|
10
|
-
*.swo
|
|
11
|
-
*~
|
|
12
|
-
.idea/
|
|
13
|
-
.vscode/
|
|
14
|
-
|
|
15
|
-
# Environment files
|
|
16
|
-
.env
|
|
17
|
-
.env.local
|
|
18
|
-
.env.*.local
|
|
19
|
-
|
|
20
|
-
# Logs
|
|
21
|
-
*.log
|
|
22
|
-
npm-debug.log*
|
|
23
|
-
|
|
24
|
-
# Sensitive files
|
|
25
|
-
credentials.json
|
|
26
|
-
*.pem
|
|
27
|
-
*.key
|
|
28
|
-
|
|
29
|
-
# Build artifacts
|
|
30
|
-
dist/
|
|
31
|
-
build/
|
|
32
|
-
|
|
33
|
-
# Temporary files
|
|
34
|
-
*.tmp
|
|
35
|
-
*.temp
|