@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.
- package/README.md +157 -0
- package/bin/install.js +340 -0
- package/package.json +40 -0
- package/src/commands/audit-claude-md.md +31 -0
- package/src/commands/audit.md +33 -0
- package/src/commands/carl-hygiene.md +33 -0
- package/src/commands/groom.md +35 -0
- package/src/commands/history.md +27 -0
- package/src/commands/pulse.md +33 -0
- package/src/commands/scaffold.md +33 -0
- package/src/commands/status.md +28 -0
- package/src/commands/surface-convert.md +35 -0
- package/src/commands/surface-create.md +34 -0
- package/src/commands/surface-list.md +27 -0
- package/src/framework/context/base-principles.md +71 -0
- package/src/framework/frameworks/audit-strategies.md +53 -0
- package/src/framework/frameworks/satellite-registration.md +44 -0
- package/src/framework/tasks/audit-claude-md.md +68 -0
- package/src/framework/tasks/audit.md +64 -0
- package/src/framework/tasks/carl-hygiene.md +160 -0
- package/src/framework/tasks/groom.md +164 -0
- package/src/framework/tasks/history.md +34 -0
- package/src/framework/tasks/pulse.md +83 -0
- package/src/framework/tasks/scaffold.md +167 -0
- package/src/framework/tasks/status.md +35 -0
- package/src/framework/tasks/surface-convert.md +143 -0
- package/src/framework/tasks/surface-create.md +184 -0
- package/src/framework/tasks/surface-list.md +42 -0
- package/src/framework/templates/active-md.md +112 -0
- package/src/framework/templates/backlog-md.md +100 -0
- package/src/framework/templates/state-md.md +48 -0
- package/src/framework/templates/workspace-json.md +50 -0
- package/src/hooks/_template.py +129 -0
- package/src/hooks/active-hook.py +115 -0
- package/src/hooks/backlog-hook.py +107 -0
- package/src/hooks/base-pulse-check.py +206 -0
- package/src/hooks/psmm-injector.py +67 -0
- package/src/hooks/satellite-detection.py +131 -0
- package/src/packages/base-mcp/index.js +108 -0
- package/src/packages/base-mcp/package.json +10 -0
- package/src/packages/base-mcp/tools/surfaces.js +404 -0
- package/src/packages/carl-mcp/index.js +115 -0
- package/src/packages/carl-mcp/package.json +10 -0
- package/src/packages/carl-mcp/tools/decisions.js +269 -0
- package/src/packages/carl-mcp/tools/domains.js +361 -0
- package/src/packages/carl-mcp/tools/psmm.js +204 -0
- package/src/packages/carl-mcp/tools/staging.js +245 -0
- 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()
|