@chrisai/base 2.3.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.
Files changed (48) hide show
  1. package/README.md +157 -0
  2. package/bin/install.js +340 -0
  3. package/package.json +40 -0
  4. package/src/commands/audit-claude-md.md +31 -0
  5. package/src/commands/audit.md +33 -0
  6. package/src/commands/carl-hygiene.md +33 -0
  7. package/src/commands/groom.md +35 -0
  8. package/src/commands/history.md +27 -0
  9. package/src/commands/pulse.md +33 -0
  10. package/src/commands/scaffold.md +33 -0
  11. package/src/commands/status.md +28 -0
  12. package/src/commands/surface-convert.md +35 -0
  13. package/src/commands/surface-create.md +34 -0
  14. package/src/commands/surface-list.md +27 -0
  15. package/src/framework/context/base-principles.md +71 -0
  16. package/src/framework/frameworks/audit-strategies.md +53 -0
  17. package/src/framework/frameworks/satellite-registration.md +44 -0
  18. package/src/framework/tasks/audit-claude-md.md +68 -0
  19. package/src/framework/tasks/audit.md +64 -0
  20. package/src/framework/tasks/carl-hygiene.md +160 -0
  21. package/src/framework/tasks/groom.md +164 -0
  22. package/src/framework/tasks/history.md +34 -0
  23. package/src/framework/tasks/pulse.md +83 -0
  24. package/src/framework/tasks/scaffold.md +167 -0
  25. package/src/framework/tasks/status.md +35 -0
  26. package/src/framework/tasks/surface-convert.md +143 -0
  27. package/src/framework/tasks/surface-create.md +184 -0
  28. package/src/framework/tasks/surface-list.md +42 -0
  29. package/src/framework/templates/active-md.md +112 -0
  30. package/src/framework/templates/backlog-md.md +100 -0
  31. package/src/framework/templates/state-md.md +48 -0
  32. package/src/framework/templates/workspace-json.md +50 -0
  33. package/src/hooks/_template.py +129 -0
  34. package/src/hooks/active-hook.py +115 -0
  35. package/src/hooks/backlog-hook.py +107 -0
  36. package/src/hooks/base-pulse-check.py +206 -0
  37. package/src/hooks/psmm-injector.py +67 -0
  38. package/src/hooks/satellite-detection.py +131 -0
  39. package/src/packages/base-mcp/index.js +108 -0
  40. package/src/packages/base-mcp/package.json +10 -0
  41. package/src/packages/base-mcp/tools/surfaces.js +404 -0
  42. package/src/packages/carl-mcp/index.js +115 -0
  43. package/src/packages/carl-mcp/package.json +10 -0
  44. package/src/packages/carl-mcp/tools/decisions.js +269 -0
  45. package/src/packages/carl-mcp/tools/domains.js +361 -0
  46. package/src/packages/carl-mcp/tools/psmm.js +204 -0
  47. package/src/packages/carl-mcp/tools/staging.js +245 -0
  48. package/src/skill/base.md +111 -0
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ BASE Hook Template — Canonical reference for data surface injection hooks.
4
+
5
+ THIS IS A TEMPLATE, NOT A RUNNABLE HOOK.
6
+ Copy this file, rename it to {surface}-hook.py, and customize the marked sections.
7
+
8
+ === CONTRACT ===
9
+ Every data surface hook MUST follow this contract:
10
+ 1. Reads ONE JSON file from .base/data/{SURFACE_NAME}.json
11
+ 2. Outputs a compact XML-tagged block to stdout
12
+ 3. Wraps output in <{SURFACE_NAME}-awareness> tags
13
+ 4. Includes a BEHAVIOR directive block
14
+ 5. Exits cleanly (exit 0) — never crashes, never blocks
15
+
16
+ === DO ===
17
+ - Read the JSON file using absolute paths (Path(__file__).resolve())
18
+ - Format a compact summary: IDs, one-line descriptions, grouped by priority/status
19
+ - Include item count summaries
20
+ - Include the behavioral directive (passive by default)
21
+ - Handle missing/empty/malformed files gracefully (output nothing, exit 0)
22
+ - Keep output compact — hooks fire every prompt, token cost matters
23
+
24
+ === DO NOT ===
25
+ - Never write to any file
26
+ - Never make network calls
27
+ - Never import heavy dependencies (sys, json, pathlib ONLY)
28
+ - Never read multiple data files (one hook = one surface)
29
+ - Never include full item details in injection (that's what MCP tools are for)
30
+ - Never include dynamic logic that changes based on time of day, session count, etc.
31
+
32
+ === TRIGGERS ===
33
+ Register in .claude/settings.json under UserPromptSubmit:
34
+ {
35
+ "type": "command",
36
+ "command": "python3 /absolute/path/to/.base/hooks/{surface}-hook.py"
37
+ }
38
+ """
39
+
40
+ import sys
41
+ import json
42
+ from pathlib import Path
43
+
44
+ # ============================================================
45
+ # CONFIGURATION — CUSTOMIZE THIS
46
+ # ============================================================
47
+
48
+ SURFACE_NAME = "example" # CHANGE THIS: your surface name (e.g., "active", "backlog")
49
+
50
+ # ============================================================
51
+ # PATH RESOLUTION — DO NOT CHANGE
52
+ # ============================================================
53
+
54
+ HOOK_DIR = Path(__file__).resolve().parent
55
+ WORKSPACE_ROOT = HOOK_DIR.parent # .base/hooks/ → .base/ → workspace root is parent of .base/
56
+ # Fix: .base/hooks/_template.py → .base/ is parent, workspace root is parent of .base/
57
+ WORKSPACE_ROOT = HOOK_DIR.parent.parent
58
+ DATA_FILE = WORKSPACE_ROOT / ".base" / "data" / f"{SURFACE_NAME}.json"
59
+
60
+ # ============================================================
61
+ # BEHAVIORAL DIRECTIVE — CUSTOMIZE IF NEEDED
62
+ # ============================================================
63
+
64
+ BEHAVIOR_DIRECTIVE = f"""BEHAVIOR: This context is PASSIVE AWARENESS ONLY.
65
+ Do NOT proactively mention these items unless:
66
+ - User explicitly asks (e.g., "what should I work on?", "what's next?")
67
+ - A deadline is within 24 hours AND user hasn't acknowledged it this session
68
+ For details on any item, use base_get_item("{SURFACE_NAME}", id)."""
69
+
70
+
71
+ def main():
72
+ # --- Read hook input from stdin (Claude Code provides session context) ---
73
+ try:
74
+ input_data = json.loads(sys.stdin.read())
75
+ session_id = input_data.get("session_id", "")
76
+ except (json.JSONDecodeError, OSError):
77
+ session_id = ""
78
+
79
+ # --- Guard: file must exist ---
80
+ if not DATA_FILE.exists():
81
+ sys.exit(0)
82
+
83
+ # --- Read and parse JSON ---
84
+ try:
85
+ data = json.loads(DATA_FILE.read_text())
86
+ except (json.JSONDecodeError, OSError):
87
+ sys.exit(0)
88
+
89
+ # ============================================================
90
+ # ITEM EXTRACTION — CUSTOMIZE THIS
91
+ # ============================================================
92
+ # Default expects: { "items": [ { "id": "...", "title": "...", ... }, ... ] }
93
+ # Adjust the key and field names to match your surface's schema.
94
+
95
+ items = data.get("items", [])
96
+
97
+ if not items:
98
+ sys.exit(0)
99
+
100
+ # ============================================================
101
+ # SUMMARY FORMATTING — CUSTOMIZE THIS
102
+ # ============================================================
103
+ # Build compact summary lines. Keep it SHORT — one line per item max.
104
+ # Group by status/priority if your schema supports it.
105
+ # Example format: "- [ID] Title (status)"
106
+
107
+ lines = []
108
+ for item in items:
109
+ item_id = item.get("id", "?")
110
+ title = item.get("title", "untitled")
111
+ status = item.get("status", "")
112
+ status_suffix = f" ({status})" if status else ""
113
+ lines.append(f"- [{item_id}] {title}{status_suffix}")
114
+
115
+ # --- Output ---
116
+ if lines:
117
+ count = len(items)
118
+ summary = "\n".join(lines)
119
+ print(f"""<{SURFACE_NAME}-awareness items="{count}">
120
+ {summary}
121
+
122
+ {BEHAVIOR_DIRECTIVE}
123
+ </{SURFACE_NAME}-awareness>""")
124
+
125
+ sys.exit(0)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ BASE Hook: active-hook.py
4
+ Surface: active (Active work items, projects, and tasks)
5
+ Source: .base/data/active.json
6
+ Output: <active-awareness> compact summary grouped by priority
7
+ """
8
+
9
+ import sys
10
+ import json
11
+ from pathlib import Path
12
+ from datetime import date
13
+
14
+ SURFACE_NAME = "active"
15
+
16
+ HOOK_DIR = Path(__file__).resolve().parent
17
+ WORKSPACE_ROOT = HOOK_DIR.parent.parent
18
+ DATA_FILE = WORKSPACE_ROOT / ".base" / "data" / f"{SURFACE_NAME}.json"
19
+
20
+ BEHAVIOR_DIRECTIVE = f"""BEHAVIOR: This context is PASSIVE AWARENESS ONLY.
21
+ Do NOT proactively mention these items unless:
22
+ - User explicitly asks (e.g., "what should I work on?", "what's next?")
23
+ - A deadline is within 24 hours AND user hasn't acknowledged it this session
24
+ For details on any item, use base_get_item("{SURFACE_NAME}", id)."""
25
+
26
+ PRIORITY_ORDER = ["urgent", "high", "medium", "ongoing", "deferred"]
27
+
28
+ # Staleness thresholds (days since last update)
29
+ STALE_THRESHOLDS = {
30
+ "urgent": 3,
31
+ "high": 5,
32
+ "medium": 7,
33
+ "ongoing": 14,
34
+ "deferred": 30,
35
+ }
36
+
37
+
38
+ def days_since_update(item):
39
+ """Calculate days since last update. Uses 'updated' if present, else 'added'."""
40
+ ts = item.get("updated") or item.get("added")
41
+ if not ts:
42
+ return None
43
+ try:
44
+ d = date.fromisoformat(ts[:10])
45
+ return (date.today() - d).days
46
+ except (ValueError, TypeError):
47
+ return None
48
+
49
+
50
+ def main():
51
+ try:
52
+ input_data = json.loads(sys.stdin.read())
53
+ except (json.JSONDecodeError, OSError):
54
+ pass
55
+
56
+ if not DATA_FILE.exists():
57
+ sys.exit(0)
58
+
59
+ try:
60
+ data = json.loads(DATA_FILE.read_text())
61
+ except (json.JSONDecodeError, OSError):
62
+ sys.exit(0)
63
+
64
+ items = data.get("items", [])
65
+ if not items:
66
+ sys.exit(0)
67
+
68
+ # Group by priority
69
+ groups = {}
70
+ for item in items:
71
+ p = item.get("priority", "medium")
72
+ groups.setdefault(p, []).append(item)
73
+
74
+ lines = []
75
+ for priority in PRIORITY_ORDER:
76
+ group = groups.get(priority, [])
77
+ if not group:
78
+ continue
79
+ lines.append(f"[{priority.upper()}]")
80
+ for item in group:
81
+ item_id = item.get("id", "?")
82
+ title = item.get("title", "untitled")
83
+ status = item.get("status", "")
84
+ parts = [f"- [{item_id}] {title}"]
85
+ if status:
86
+ parts[0] += f" ({status})"
87
+ blocked = item.get("blocked")
88
+ if blocked:
89
+ parts.append(f" BLOCKED: {blocked}")
90
+ deadline = item.get("deadline")
91
+ if deadline:
92
+ parts.append(f" DUE: {deadline}")
93
+ days = days_since_update(item)
94
+ threshold = STALE_THRESHOLDS.get(priority, 7)
95
+ if days is not None:
96
+ if days >= threshold:
97
+ parts.append(f" STALE: {days}d since update (threshold: {threshold}d)")
98
+ else:
99
+ parts.append(f" updated: {days}d ago")
100
+ lines.append("\n".join(parts))
101
+
102
+ if lines:
103
+ count = len(items)
104
+ summary = "\n".join(lines)
105
+ print(f"""<{SURFACE_NAME}-awareness items="{count}">
106
+ {summary}
107
+
108
+ {BEHAVIOR_DIRECTIVE}
109
+ </{SURFACE_NAME}-awareness>""")
110
+
111
+ sys.exit(0)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ BASE Hook: backlog-hook.py
4
+ Surface: backlog (Future work queue, ideas, and deferred tasks)
5
+ Source: .base/data/backlog.json
6
+ Output: <backlog-awareness> compact summary grouped by priority
7
+ """
8
+
9
+ import sys
10
+ import json
11
+ from pathlib import Path
12
+ from datetime import date
13
+
14
+ SURFACE_NAME = "backlog"
15
+
16
+ HOOK_DIR = Path(__file__).resolve().parent
17
+ WORKSPACE_ROOT = HOOK_DIR.parent.parent
18
+ DATA_FILE = WORKSPACE_ROOT / ".base" / "data" / f"{SURFACE_NAME}.json"
19
+
20
+ BEHAVIOR_DIRECTIVE = f"""BEHAVIOR: This context is PASSIVE AWARENESS ONLY.
21
+ Do NOT proactively mention these items unless:
22
+ - User explicitly asks (e.g., "what's in the backlog?", "what's queued?")
23
+ - A review_by date has passed AND user hasn't acknowledged it this session
24
+ For details on any item, use base_get_item("{SURFACE_NAME}", id)."""
25
+
26
+ PRIORITY_ORDER = ["high", "medium", "low"]
27
+
28
+ # Staleness thresholds (days since last update)
29
+ STALE_THRESHOLDS = {
30
+ "high": 7,
31
+ "medium": 14,
32
+ "low": 30,
33
+ }
34
+
35
+
36
+ def days_since_update(item):
37
+ """Calculate days since last update. Uses 'updated' if present, else 'added'."""
38
+ ts = item.get("updated") or item.get("added")
39
+ if not ts:
40
+ return None
41
+ try:
42
+ d = date.fromisoformat(ts[:10])
43
+ return (date.today() - d).days
44
+ except (ValueError, TypeError):
45
+ return None
46
+
47
+
48
+ def main():
49
+ try:
50
+ input_data = json.loads(sys.stdin.read())
51
+ except (json.JSONDecodeError, OSError):
52
+ pass
53
+
54
+ if not DATA_FILE.exists():
55
+ sys.exit(0)
56
+
57
+ try:
58
+ data = json.loads(DATA_FILE.read_text())
59
+ except (json.JSONDecodeError, OSError):
60
+ sys.exit(0)
61
+
62
+ items = data.get("items", [])
63
+ if not items:
64
+ sys.exit(0)
65
+
66
+ # Group by priority
67
+ groups = {}
68
+ for item in items:
69
+ p = item.get("priority", "medium")
70
+ groups.setdefault(p, []).append(item)
71
+
72
+ lines = []
73
+ for priority in PRIORITY_ORDER:
74
+ group = groups.get(priority, [])
75
+ if not group:
76
+ continue
77
+ lines.append(f"[{priority.upper()}]")
78
+ for item in group:
79
+ item_id = item.get("id", "?")
80
+ title = item.get("title", "untitled")
81
+ review_by = item.get("review_by")
82
+ entry = f"- [{item_id}] {title}"
83
+ if review_by:
84
+ entry += f" [review by: {review_by}]"
85
+ days = days_since_update(item)
86
+ threshold = STALE_THRESHOLDS.get(priority, 14)
87
+ if days is not None:
88
+ if days >= threshold:
89
+ entry += f" STALE: {days}d"
90
+ else:
91
+ entry += f" ({days}d ago)"
92
+ lines.append(entry)
93
+
94
+ if lines:
95
+ count = len(items)
96
+ summary = "\n".join(lines)
97
+ print(f"""<{SURFACE_NAME}-awareness items="{count}">
98
+ {summary}
99
+
100
+ {BEHAVIOR_DIRECTIVE}
101
+ </{SURFACE_NAME}-awareness>""")
102
+
103
+ sys.exit(0)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: base-pulse-check.py
4
+ Purpose: BASE workspace health check on session start.
5
+ Reads .base/STATE.md and workspace.json, calculates drift,
6
+ injects warning if grooming is overdue.
7
+ Triggers: UserPromptSubmit (session context)
8
+ Output: Workspace health status or groom reminder
9
+ """
10
+
11
+ import sys
12
+ import json
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ # Workspace root — find .base/ relative to this hook's location
17
+ HOOK_DIR = Path(__file__).resolve().parent
18
+ WORKSPACE_ROOT = HOOK_DIR.parent.parent # .claude/hooks/ -> .claude/ -> workspace
19
+ BASE_DIR = WORKSPACE_ROOT / ".base"
20
+ STATE_FILE = BASE_DIR / "STATE.md"
21
+ MANIFEST_FILE = BASE_DIR / "workspace.json"
22
+
23
+
24
+ class WorkspaceState:
25
+ """Parsed state from STATE.md."""
26
+ def __init__(self):
27
+ self.last_groom: datetime | None = None
28
+ self.next_groom_due: datetime | None = None
29
+ self.drift_score: int | None = None
30
+
31
+
32
+ def parse_state_md(content: str) -> WorkspaceState:
33
+ """Extract key dates and drift info from STATE.md."""
34
+ state = WorkspaceState()
35
+
36
+ for line in content.split("\n"):
37
+ if line.startswith("**Last Groom:**"):
38
+ date_str = line.replace("**Last Groom:**", "").strip()
39
+ try:
40
+ state.last_groom = datetime.strptime(date_str, "%Y-%m-%d")
41
+ except ValueError:
42
+ pass
43
+ elif line.startswith("**Next Groom Due:**"):
44
+ date_str = line.replace("**Next Groom Due:**", "").strip()
45
+ try:
46
+ state.next_groom_due = datetime.strptime(date_str, "%Y-%m-%d")
47
+ except ValueError:
48
+ pass
49
+ elif line.startswith("**Drift Score:**"):
50
+ score_str = line.replace("**Drift Score:**", "").strip().split()[0]
51
+ try:
52
+ state.drift_score = int(score_str)
53
+ except ValueError:
54
+ pass
55
+
56
+ return state
57
+
58
+
59
+ def check_file_staleness(workspace_root, manifest):
60
+ """Check tracked files/directories for staleness based on manifest."""
61
+ stale_areas = []
62
+ now = datetime.now()
63
+
64
+ areas = manifest.get("areas", {})
65
+ for area_name, area_config in areas.items():
66
+ groom_cadence = area_config.get("groom", "weekly")
67
+ paths = area_config.get("paths", [])
68
+
69
+ # Convert cadence to days
70
+ cadence_days = {"weekly": 7, "bi-weekly": 14, "monthly": 30}.get(groom_cadence, 7)
71
+
72
+ for p in paths:
73
+ full_path = workspace_root / p
74
+ if full_path.exists():
75
+ if full_path.is_file():
76
+ mtime = datetime.fromtimestamp(full_path.stat().st_mtime)
77
+ elif full_path.is_dir():
78
+ # Use most recent file modification in directory
79
+ try:
80
+ mtimes = [
81
+ f.stat().st_mtime
82
+ for f in full_path.iterdir()
83
+ if f.is_file() and not f.name.startswith(".")
84
+ ]
85
+ mtime = datetime.fromtimestamp(max(mtimes)) if mtimes else datetime.fromtimestamp(full_path.stat().st_mtime)
86
+ except (OSError, ValueError):
87
+ mtime = datetime.fromtimestamp(full_path.stat().st_mtime)
88
+ else:
89
+ continue
90
+
91
+ age_days = (now - mtime).days
92
+ if age_days > cadence_days:
93
+ stale_areas.append({
94
+ "area": area_name,
95
+ "path": p,
96
+ "age_days": age_days,
97
+ "threshold": cadence_days,
98
+ "overdue_by": age_days - cadence_days,
99
+ })
100
+
101
+ return stale_areas
102
+
103
+
104
+ def main():
105
+ # Skip if BASE is not installed
106
+ if not BASE_DIR.exists() or not MANIFEST_FILE.exists():
107
+ # BASE not installed — silent exit
108
+ sys.exit(0)
109
+
110
+ try:
111
+ with open(MANIFEST_FILE, "r") as f:
112
+ manifest = json.load(f)
113
+ except (json.JSONDecodeError, OSError):
114
+ sys.exit(0)
115
+
116
+ # Read STATE.md if it exists
117
+ state = WorkspaceState()
118
+ if STATE_FILE.exists():
119
+ try:
120
+ with open(STATE_FILE, "r") as f:
121
+ state = parse_state_md(f.read())
122
+ except OSError:
123
+ pass
124
+
125
+ now = datetime.now()
126
+ groom_cadence = manifest.get("groom_cadence", "weekly")
127
+ cadence_days = {"weekly": 7, "bi-weekly": 14, "monthly": 30}.get(groom_cadence, 7)
128
+
129
+ # Check if groom is overdue
130
+ groom_overdue = False
131
+ days_since_groom = None
132
+ if state.last_groom:
133
+ days_since_groom = (now - state.last_groom).days
134
+ groom_overdue = days_since_groom > cadence_days
135
+
136
+ # Check file staleness
137
+ stale_areas = check_file_staleness(WORKSPACE_ROOT, manifest)
138
+
139
+ # Calculate drift score
140
+ drift_score = sum(area["overdue_by"] for area in stale_areas if area["overdue_by"] > 0)
141
+
142
+ # Check CARL hygiene (optional proactive reminder)
143
+ carl_hygiene_reminder = None
144
+ carl_hygiene_config = manifest.get("carl_hygiene", {})
145
+ if carl_hygiene_config.get("proactive", False):
146
+ hygiene_cadence = {"weekly": 7, "bi-weekly": 14, "monthly": 30}.get(
147
+ carl_hygiene_config.get("cadence", "monthly"), 30
148
+ )
149
+ last_hygiene = carl_hygiene_config.get("last_run")
150
+ if last_hygiene:
151
+ try:
152
+ last_hygiene_date = datetime.strptime(last_hygiene, "%Y-%m-%d")
153
+ days_since_hygiene = (now - last_hygiene_date).days
154
+ if days_since_hygiene > hygiene_cadence:
155
+ carl_hygiene_reminder = f"CARL hygiene overdue ({days_since_hygiene}d since last run). Run /base:carl-hygiene"
156
+ except ValueError:
157
+ carl_hygiene_reminder = "CARL hygiene: last_run date invalid. Run /base:carl-hygiene"
158
+ else:
159
+ carl_hygiene_reminder = "CARL hygiene never run. Run /base:carl-hygiene when ready"
160
+
161
+ # Also check for staged proposals
162
+ staging_file = BASE_DIR / "staging.json"
163
+ if staging_file.exists():
164
+ try:
165
+ staging_data = json.loads(staging_file.read_text())
166
+ pending = [p for p in staging_data.get("proposals", []) if p.get("status") == "pending"]
167
+ if pending:
168
+ carl_hygiene_reminder = (carl_hygiene_reminder or "") + f" | {len(pending)} staged proposals pending"
169
+ except (json.JSONDecodeError, OSError):
170
+ pass
171
+
172
+ # Build output
173
+ output_parts = []
174
+
175
+ if groom_overdue and days_since_groom is not None and state.last_groom is not None:
176
+ output_parts.append(
177
+ f"BASE: Workspace groom overdue by {days_since_groom - cadence_days} days "
178
+ f"(last groom: {state.last_groom.strftime('%Y-%m-%d')}). "
179
+ f"Run /base:groom to maintain workspace health."
180
+ )
181
+
182
+ if stale_areas:
183
+ stale_names = [a["area"] for a in stale_areas]
184
+ output_parts.append(
185
+ f"BASE drift score: {drift_score} | Stale areas: {', '.join(stale_names)}"
186
+ )
187
+ elif not groom_overdue and state.last_groom is not None:
188
+ # Everything is fine — minimal output
189
+ output_parts.append(
190
+ f"BASE: Drift 0 | Last groom: {state.last_groom.strftime('%Y-%m-%d')} | All areas current"
191
+ )
192
+
193
+ if carl_hygiene_reminder:
194
+ output_parts.append(carl_hygiene_reminder)
195
+
196
+ if output_parts:
197
+ print(f"""<base-pulse>
198
+ {chr(10).join(output_parts)}
199
+ </base-pulse>""")
200
+ # Silent if no state yet
201
+
202
+ sys.exit(0)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: psmm-injector.py
4
+ Purpose: Per-Session Meta Memory — inject ephemeral session observations
5
+ into every prompt so they stay hot in long sessions (1M window).
6
+
7
+ Uses a single psmm.json file with session-keyed entries.
8
+ Each session gets its own array keyed by Claude Code session UUID.
9
+ Stale sessions are NOT auto-cleaned — that's the operator's job
10
+ via CARL hygiene / BASE drift detection.
11
+
12
+ Triggers: UserPromptSubmit
13
+ Output: Current session's PSMM entries as system context, or silent if empty.
14
+ """
15
+
16
+ import sys
17
+ import json
18
+ from pathlib import Path
19
+
20
+ HOOK_DIR = Path(__file__).resolve().parent
21
+ WORKSPACE_ROOT = HOOK_DIR.parent.parent
22
+ PSMM_FILE = WORKSPACE_ROOT / ".base" / "data" / "psmm.json"
23
+
24
+
25
+ def main():
26
+ # Get session_id from hook input
27
+ try:
28
+ input_data = json.loads(sys.stdin.read())
29
+ session_id = input_data.get("session_id", "")
30
+ except (json.JSONDecodeError, OSError):
31
+ session_id = ""
32
+
33
+ if not session_id or not PSMM_FILE.exists():
34
+ sys.exit(0)
35
+
36
+ try:
37
+ data = json.loads(PSMM_FILE.read_text())
38
+ except (json.JSONDecodeError, OSError):
39
+ sys.exit(0)
40
+
41
+ sessions = data.get("sessions", {})
42
+ session = sessions.get(session_id)
43
+
44
+ if not session or not session.get("entries"):
45
+ sys.exit(0)
46
+
47
+ # Build output from this session's entries
48
+ entries = session["entries"]
49
+ lines = []
50
+ for entry in entries:
51
+ entry_type = entry.get("type", "NOTE")
52
+ text = entry.get("text", "")
53
+ timestamp = entry.get("timestamp", "")
54
+ lines.append(f"- [{timestamp}] {entry_type}: {text}")
55
+
56
+ if lines:
57
+ created = session.get("created", "unknown")
58
+ count = len(entries)
59
+ print(f"""<psmm session="{session_id[:8]}" entries="{count}" created="{created}">
60
+ {chr(10).join(lines)}
61
+ </psmm>""")
62
+
63
+ sys.exit(0)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()