leopold-driver 0.1.1 → 0.1.2

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 +19 -5
  2. package/assets/VERSION +1 -0
  3. package/assets/extensions/README.md +52 -0
  4. package/assets/extensions/gstack/extension.json +8 -0
  5. package/assets/extensions/gstack/manage.sh +68 -0
  6. package/assets/extensions/leopold/extension.json +8 -0
  7. package/assets/extensions/leopold/manage.sh +59 -0
  8. package/assets/extensions/ovmem/README.md +101 -0
  9. package/assets/extensions/ovmem/extension.json +8 -0
  10. package/assets/extensions/ovmem/install.sh +330 -0
  11. package/assets/extensions/ovmem/manage.sh +87 -0
  12. package/assets/extensions/ovmem/models.json +24 -0
  13. package/assets/extensions/ovmem/payload/RUNTIME.md +121 -0
  14. package/assets/extensions/ovmem/payload/ovmem-cleanup.py +148 -0
  15. package/assets/extensions/ovmem/payload/ovmem.py +421 -0
  16. package/assets/extensions/serena/README.md +50 -0
  17. package/assets/extensions/serena/extension.json +8 -0
  18. package/assets/extensions/serena/manage.sh +119 -0
  19. package/assets/hooks/guard-irreversible.sh +185 -0
  20. package/assets/hooks/hooks.json +20 -0
  21. package/assets/hooks/stop-continuity.sh +132 -0
  22. package/assets/install.sh +150 -0
  23. package/assets/scripts/__pycache__/leopold-watch.cpython-312.pyc +0 -0
  24. package/assets/scripts/leopold-doctor.sh +53 -0
  25. package/assets/scripts/leopold-menu.sh +132 -0
  26. package/assets/scripts/leopold-update-check.sh +23 -0
  27. package/assets/scripts/leopold-update.sh +13 -0
  28. package/assets/scripts/leopold-watch.py +585 -0
  29. package/assets/scripts/record-demo.sh +61 -0
  30. package/assets/scripts/test-guard.sh +76 -0
  31. package/assets/scripts/test-hooks.sh +121 -0
  32. package/assets/settings.template.json +23 -0
  33. package/assets/skills/leopold-brief/SKILL.md +121 -0
  34. package/assets/skills/leopold-doctor/SKILL.md +23 -0
  35. package/assets/skills/leopold-run/SKILL.md +171 -0
  36. package/assets/skills/leopold-status/SKILL.md +34 -0
  37. package/assets/skills/leopold-stop/SKILL.md +36 -0
  38. package/assets/skills/leopold-update/SKILL.md +27 -0
  39. package/assets/skills/leopold-watch/SKILL.md +48 -0
  40. package/assets/templates/CHARTER.md +32 -0
  41. package/assets/templates/DECISIONS.md +15 -0
  42. package/assets/templates/GUARDRAILS.md +38 -0
  43. package/assets/templates/MISSION.md +22 -0
  44. package/assets/templates/PLAN.md +9 -0
  45. package/dist/guard.js +82 -23
  46. package/dist/harness.js +71 -0
  47. package/dist/index.js +53 -23
  48. package/package.json +6 -3
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # serena extension — LSP-backed code intelligence (MCP) for Claude Code.
3
+ #
4
+ # Serena gives the agent IDE-grade, symbol-level tools (find_symbol,
5
+ # find_referencing_symbols, replace_symbol_body, ...) instead of grep + whole-file
6
+ # reads. That means sharper edits AND far fewer tokens — which is exactly the lever
7
+ # Leopold needs to keep an autonomous run's context lean. Mandatory for quality.
8
+ #
9
+ # Setup is the OFFICIAL path (uv tool + `claude mcp add`), NOT the MCP marketplace —
10
+ # the marketplace ships outdated commands (per the Serena maintainers).
11
+ set -euo pipefail
12
+
13
+ CLAUDE="${CLAUDE_HOME:-$HOME/.claude}"
14
+ SETTINGS="$CLAUDE/settings.json"
15
+ BIN="$HOME/.local/bin"
16
+ PKG="serena-agent" # PyPI package -> `serena` + `serena-hooks`
17
+ MCP_ARGS="start-mcp-server --context=claude-code --project-from-cwd"
18
+
19
+ say() { printf '\033[36m->\033[0m %s\n' "$*"; }
20
+ ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
21
+ warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
22
+ have() { command -v "$1" >/dev/null 2>&1; }
23
+ sver() { serena --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1; }
24
+ mcp_present() { have claude && claude mcp get serena >/dev/null 2>&1; }
25
+ hooks_wired() { [ -f "$SETTINGS" ] && grep -q 'serena-hooks ' "$SETTINGS" 2>/dev/null; }
26
+
27
+ wire_hooks() {
28
+ have jq || { warn "jq missing; skipping Serena hook wiring (recommended for tool adherence)"; return 0; }
29
+ [ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
30
+ cp "$SETTINGS" "$SETTINGS.serena.bak"
31
+ local rm="serena-hooks remind --client=claude-code" ap="serena-hooks auto-approve --client=claude-code"
32
+ local ac="serena-hooks activate --client=claude-code" cl="serena-hooks cleanup --client=claude-code"
33
+ local tmp; tmp="$(mktemp)"
34
+ jq --arg rm "$rm" --arg ap "$ap" --arg ac "$ac" --arg cl "$cl" '
35
+ .hooks //= {} | .hooks.PreToolUse //= [] | .hooks.SessionStart //= [] | .hooks.SessionEnd //= []
36
+ | (if any(.hooks.PreToolUse[]?.hooks[]?; .command==$rm) then . else .hooks.PreToolUse += [{matcher:"",hooks:[{type:"command",command:$rm}]}] end)
37
+ | (if any(.hooks.PreToolUse[]?.hooks[]?; .command==$ap) then . else .hooks.PreToolUse += [{matcher:"mcp__serena__*",hooks:[{type:"command",command:$ap}]}] end)
38
+ | (if any(.hooks.SessionStart[]?.hooks[]?; .command==$ac) then . else .hooks.SessionStart += [{hooks:[{type:"command",command:$ac}]}] end)
39
+ | (if any(.hooks.SessionEnd[]?.hooks[]?; .command==$cl) then . else .hooks.SessionEnd += [{hooks:[{type:"command",command:$cl}]}] end)
40
+ ' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
41
+ ok "Serena hooks wired: remind + auto-approve + activate + cleanup (backup at $SETTINGS.serena.bak)"
42
+ }
43
+
44
+ do_install() {
45
+ if ! have uv; then
46
+ say "installing uv (Serena's only prerequisite)"
47
+ curl -LsSf https://astral.sh/uv/install.sh | sh || { warn "uv install failed"; return 1; }
48
+ export PATH="$BIN:$PATH"
49
+ fi
50
+ if have serena && [ "${1:-install}" != update ]; then
51
+ ok "serena already installed ($(sver))"
52
+ else
53
+ say "installing Serena (LSP code intelligence)"
54
+ have serena || printf ' \033[2m(downloading serena-agent + deps — up to a minute on first install)\033[0m\n'
55
+ uv tool install -p 3.13 "$PKG" || uv tool install "$PKG" \
56
+ || { warn "uv tool install $PKG failed"; return 1; }
57
+ export PATH="$BIN:$PATH"
58
+ fi
59
+ have serena || { warn "serena not on PATH after install (add $BIN to PATH)"; return 1; }
60
+ ok "serena $(sver) ready"
61
+
62
+ # Global config is created automatically on first run; only init if it is missing.
63
+ if [ ! -f "$HOME/.serena/serena_config.yml" ]; then
64
+ serena init </dev/null >/dev/null 2>&1 || warn "serena init skipped (the MCP server creates the global config on first start)"
65
+ fi
66
+
67
+ # Register the MCP server (user scope = all projects). claude mcp add is NOT idempotent,
68
+ # so only add when it is not already present.
69
+ if have claude; then
70
+ if mcp_present; then ok "Serena MCP already registered (user scope)"
71
+ else
72
+ say "registering Serena MCP for Claude Code (user scope, all projects)"
73
+ if claude mcp add --scope user serena -- serena $MCP_ARGS >/dev/null 2>&1; then ok "MCP registered"
74
+ else warn "couldn't auto-register; run: claude mcp add --scope user serena -- serena $MCP_ARGS"; fi
75
+ fi
76
+ else
77
+ warn "the 'claude' CLI is not on PATH — register manually once it is:"
78
+ warn " claude mcp add --scope user serena -- serena $MCP_ARGS"
79
+ fi
80
+
81
+ # Recommended hooks (counter Claude Code's bias toward its built-in tools).
82
+ hooks_wired && ok "Serena hooks already wired" || wire_hooks
83
+ echo
84
+ ok "Serena ready. Reconnect with /mcp (or restart Claude Code) to load its tools."
85
+ }
86
+
87
+ case "${1:-}" in
88
+ detect) have serena ;;
89
+ status)
90
+ if have serena; then
91
+ printf 'serena %s' "$(sver)"
92
+ mcp_present && printf ' · MCP connected' || printf ' · MCP not registered'
93
+ hooks_wired && printf ' · hooks on'; echo
94
+ else echo "not installed"; fi
95
+ ;;
96
+ install) do_install install ;;
97
+ update) do_install update ;;
98
+ remove)
99
+ if mcp_present; then claude mcp remove serena -s user >/dev/null 2>&1 && echo "unregistered Serena MCP"; fi
100
+ if [ -f "$SETTINGS" ] && have jq; then
101
+ cp "$SETTINGS" "$SETTINGS.serena.bak"
102
+ tmp="$(mktemp)"
103
+ jq 'if .hooks then .hooks |= ( to_entries
104
+ | map(.value |= ( map(.hooks |= map(select((.command // "") | test("serena-hooks ") | not)))
105
+ | map(select((.hooks | length) > 0)) ))
106
+ | from_entries ) else . end' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
107
+ echo "unwired Serena hooks (backup at $SETTINGS.serena.bak)"
108
+ fi
109
+ echo "left installed: the serena CLI. To remove it too: uv tool uninstall $PKG"
110
+ ;;
111
+ doctor)
112
+ echo "serena: $(have serena && serena --version 2>/dev/null | head -1 || echo missing)"
113
+ echo "MCP: $(mcp_present && echo "registered (user scope)" || echo "not registered")"
114
+ _hc="$(grep -c 'serena-hooks ' "$SETTINGS" 2>/dev/null || true)"; echo "hooks: ${_hc:-0}/4 Serena hooks in settings.json"
115
+ echo "uv: $(have uv && echo present || echo missing)"
116
+ have claude || echo "note: 'claude' CLI not found — MCP registration must be done manually"
117
+ ;;
118
+ *) echo "usage: manage.sh {detect|status|install|update|remove|doctor}" >&2; exit 2 ;;
119
+ esac
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bash
2
+ # Leopold PreToolUse guard: keeps irreversible / outbound actions locked while a
3
+ # Leopold autonomous run is active. This is defense-in-depth on top of Claude
4
+ # Code's own permission system. It never loosens anything; it only adds denials.
5
+ #
6
+ # Hook contract (Claude Code PreToolUse): read JSON on stdin. To block, print a
7
+ # hookSpecificOutput object with permissionDecision "deny" and exit 0. To allow,
8
+ # exit 0 with no output. Detection is intentionally over-broad: when in doubt it
9
+ # blocks, because a blocked-but-safe op just means the human runs it.
10
+ #
11
+ # Each denial below has a matching case in scripts/test-guard.sh (red-team suite).
12
+
13
+ input="$(cat 2>/dev/null || true)"
14
+ command -v jq >/dev/null 2>&1 || exit 0 # cannot parse safely -> defer to CC perms
15
+
16
+ cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
17
+ [ -z "${cwd:-}" ] && cwd="$PWD"
18
+ LEO="$cwd/.leopold"
19
+ STATE="$LEO/state.json"
20
+
21
+ # Only guard during an active autonomous run.
22
+ [ -f "$STATE" ] || exit 0
23
+ # Fail CLOSED on a malformed state.json: if the file exists but does not parse,
24
+ # we cannot prove the run is inactive, so we keep guarding (and say so).
25
+ if ! jq -e . "$STATE" >/dev/null 2>&1; then
26
+ active="true"
27
+ else
28
+ active="$(jq -r '.active // false' "$STATE" 2>/dev/null || echo true)"
29
+ fi
30
+ [ "$active" = "true" ] || exit 0
31
+
32
+ tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
33
+
34
+ deny() {
35
+ local r="$1" ts
36
+ jq -cn --arg r "$r" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
37
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '')"
38
+ printf '{"ts":"%s","event":"guard_block","tool":"%s"}\n' "$ts" "$tool" >> "$LEO/events.jsonl" 2>/dev/null || true
39
+ exit 0
40
+ }
41
+
42
+ has_token() { [ -f "$LEO/$1" ]; }
43
+ matches() { printf '%s' "$1" | grep -qiE -- "$2"; } # $1 text, $2 ERE (case-insensitive)
44
+
45
+ # Resolve the git subcommand, skipping leading global options AND their values
46
+ # (-c k=v, -C path, --git-dir, --work-tree, --namespace, --exec-path, --config-env).
47
+ # This is what closes the `git -c user.name=x commit` / `git -C /r commit` bypass.
48
+ git_subcmd() { # $1 = normalized command -> echoes the subcommand (or empty)
49
+ local toks=() i n t want=0
50
+ read -ra toks <<< "$1"
51
+ n=${#toks[@]}; i=0
52
+ # match 'git', '/usr/bin/git', './git', 'env git' ... by basename
53
+ while [ "$i" -lt "$n" ] && [ "${toks[$i]##*/}" != "git" ]; do i=$((i+1)); done
54
+ i=$((i+1))
55
+ while [ "$i" -lt "$n" ]; do
56
+ t="${toks[$i]}"
57
+ if [ "$want" -eq 1 ]; then want=0; i=$((i+1)); continue; fi
58
+ case "$t" in
59
+ -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env) want=1 ;;
60
+ --*=*|-*) : ;; # self-contained option, skip
61
+ *) printf '%s' "$t"; return ;;
62
+ esac
63
+ i=$((i+1))
64
+ done
65
+ }
66
+
67
+ case "$tool" in
68
+ Task|Agent)
69
+ # Subagent fan-out is the #1 autonomous-run cost blowup, on TWO axes — how MANY are
70
+ # spawned, and how much CONTEXT each one carries. Guard both.
71
+ stype="$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // empty' 2>/dev/null || true)"
72
+
73
+ # (a) Context per subagent: an oversized prompt means context is being dumped into the
74
+ # subagent (each is billed in full). A normal scoped prompt is a few KB; deny the absurd.
75
+ plen="$(printf '%s' "$input" | jq -r '.tool_input.prompt // .tool_input.description // empty' 2>/dev/null | wc -c | tr -d ' ')"
76
+ if [ "${plen:-0}" -gt 262144 ] 2>/dev/null; then
77
+ deny "Leopold guard: this subagent prompt is ~$(( ${plen:-0} / 1024 ))KB — you're handing the subagent a huge context (each spawn is billed in full). Pass a minimal, scoped prompt: point it at files to read, don't paste them in."
78
+ fi
79
+
80
+ # (b) Forks clone the ENTIRE parent session (multi-MB) — they ARE the per-subagent
81
+ # context leak. Forbidden by default (max_forks 0); a fresh scoped subagent does the
82
+ # same work with a clean slate. Raise max_forks in GUARDRAILS.md only for a sub-task
83
+ # that genuinely needs the full conversation.
84
+ if [ "$stype" = "fork" ]; then
85
+ max_fk="$(jq -r '.max_forks // 0' "$STATE" 2>/dev/null || echo 0)"; case "$max_fk" in (*[!0-9]*|"") max_fk=0 ;; esac
86
+ forked="$(jq -r '.forks_spawned // 0' "$STATE" 2>/dev/null || echo 0)"; case "$forked" in (*[!0-9]*|"") forked=0 ;; esac
87
+ if [ "$forked" -ge "$max_fk" ] 2>/dev/null; then
88
+ deny "Leopold guard: forks are forbidden in autonomous mode ($forked/$max_fk). A fork clones the WHOLE session context (multi-MB) into the spawn — this is the leak that runs up the bill. Use a regular subagent with a minimal prompt instead (it starts clean). To allow a fork, raise max_forks in GUARDRAILS.md."
89
+ fi
90
+ gtmp="$(mktemp 2>/dev/null || echo "$STATE.gtmp")"
91
+ jq --argjson f "$((forked+1))" '.forks_spawned=$f' "$STATE" > "$gtmp" 2>/dev/null && mv "$gtmp" "$STATE" || rm -f "$gtmp" 2>/dev/null
92
+ fi
93
+
94
+ # (c) Total subagent budget (forks count here too). Past it, deny so the run continues
95
+ # serially instead of exploding. Counters live in state.json (best-effort).
96
+ max_sub="$(jq -r '.max_subagents // 8' "$STATE" 2>/dev/null || echo 8)"; case "$max_sub" in (*[!0-9]*|"") max_sub=8 ;; esac
97
+ spawned="$(jq -r '.subagents_spawned // 0' "$STATE" 2>/dev/null || echo 0)"; case "$spawned" in (*[!0-9]*|"") spawned=0 ;; esac
98
+ if [ "$spawned" -ge "$max_sub" ] 2>/dev/null; then
99
+ deny "Leopold guard: subagent budget exhausted ($spawned/$max_sub this run). Spawning many subagents multiplies cost — each re-loads context. Do this task yourself in-turn (work serially). Raise max_subagents in GUARDRAILS.md and restart if you truly must."
100
+ fi
101
+ gtmp="$(mktemp 2>/dev/null || echo "$STATE.gtmp")"
102
+ jq --argjson s "$((spawned+1))" '.subagents_spawned=$s' "$STATE" > "$gtmp" 2>/dev/null && mv "$gtmp" "$STATE" || rm -f "$gtmp" 2>/dev/null
103
+ # Audit trail: one line per spawn (size + fork flag) so a run's cost is inspectable.
104
+ sts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '')"
105
+ printf '{"ts":"%s","event":"subagent_spawn","fork":%s,"prompt_kb":%s,"total":%s}\n' \
106
+ "$sts" "$([ "$stype" = fork ] && echo true || echo false)" "$(( ${plen:-0} / 1024 ))" "$((spawned+1))" \
107
+ >> "$LEO/events.jsonl" 2>/dev/null || true
108
+ ;;
109
+
110
+ Bash)
111
+ cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
112
+ # normalize: newlines/tabs -> space, collapse runs (defeats whitespace/tab evasion).
113
+ norm="$(printf '%s' "$cmd" | tr '\n\t' ' ' | tr -s ' ')"
114
+
115
+ # Opt-in deny-by-default (LEOPOLD_PARANOID=1): only a small allowlist of
116
+ # read/build/test/lint commands passes; everything else is denied. Best-effort
117
+ # (it keys off the first command word), kept off by default in favor of the
118
+ # hardened denylist below. Documented in docs/guardrails.md.
119
+ if [ "${LEOPOLD_PARANOID:-0}" = "1" ]; then
120
+ first="$(printf '%s' "$norm" | awk '{print $1}')"; base="${first##*/}"; ok=0
121
+ case "$base" in
122
+ ls|cat|head|tail|wc|grep|rg|awk|sed|find|file|stat|tree|pwd|cd|echo|printf|true|jq|make|node|npx|tsc|mkdir|cp|touch|test|diff|cmp) ok=1 ;;
123
+ git) case "$(git_subcmd "$norm")" in add|status|diff|log|show|rev-parse|branch|fetch|remote|config) ok=1 ;; esac ;;
124
+ npm|pnpm|yarn) matches "$norm" '[[:space:]](run|test|ci|pack|install|build|typecheck|lint)([[:space:]]|$)' && ok=1 ;;
125
+ esac
126
+ [ "$ok" = 1 ] || deny "Leopold PARANOID: '$base' is not on the allowlist (read/build/test/lint/git add). Unset LEOPOLD_PARANOID to use the default denylist."
127
+ fi
128
+
129
+ # --- destructive deletes (rm recursive+force, in any spelling) ----------
130
+ if matches "$norm" '(^|[^[:alnum:]_-])rm([[:space:]]|$)'; then
131
+ rec=0; force=0
132
+ matches "$norm" '(--recursive|(^|[[:space:]])-[a-z]*r)' && rec=1
133
+ matches "$norm" '(--force|(^|[[:space:]])-[a-z]*f)' && force=1
134
+ [ "$rec" = 1 ] && [ "$force" = 1 ] && \
135
+ deny "Leopold guard: recursive+forced rm is forbidden in autonomous mode (any spelling). Stop and let the human run it."
136
+ fi
137
+ # find-based deletion
138
+ if matches "$norm" '(^|[^[:alnum:]_-])find([[:space:]]|$)'; then
139
+ matches "$norm" '[[:space:]]-delete([[:space:]]|$)' && \
140
+ deny "Leopold guard: 'find ... -delete' is forbidden in autonomous mode."
141
+ matches "$norm" '-exec[[:space:]]+rm([[:space:]]|$)' && \
142
+ deny "Leopold guard: 'find ... -exec rm' is forbidden in autonomous mode."
143
+ fi
144
+
145
+ # --- git, by resolved subcommand (bypass-resistant) ---------------------
146
+ gsub="$(git_subcmd "$norm")"
147
+ case "$gsub" in
148
+ reset) matches "$norm" '(^|[[:space:]])--hard([[:space:]]|$)' && \
149
+ deny "Leopold guard: 'git reset --hard' is forbidden in autonomous mode." ;;
150
+ clean) matches "$norm" '(--force|(^|[[:space:]])-[a-z]*f)' && \
151
+ deny "Leopold guard: 'git clean -f' is forbidden in autonomous mode." ;;
152
+ branch) matches "$norm" '(^|[[:space:]])-D([[:space:]]|$)' && \
153
+ deny "Leopold guard: 'git branch -D' is forbidden in autonomous mode." ;;
154
+ push)
155
+ matches "$norm" '(--force|--force-with-lease|(^|[[:space:]])-f([[:space:]]|$))' && \
156
+ deny "Leopold guard: force-push is forbidden in autonomous mode."
157
+ has_token ALLOW_PUSH || \
158
+ deny "Leopold guard: git push is locked. Pushing is the user's call; report readiness instead." ;;
159
+ commit)
160
+ has_token ALLOW_GIT || \
161
+ deny "Leopold guard: git commit is locked. Stage the work (git add) and report instead. To allow this session: touch .leopold/ALLOW_GIT" ;;
162
+ esac
163
+
164
+ # --- outbound: PRs / releases / publishing ------------------------------
165
+ if matches "$norm" '(^|[^[:alnum:]_-])gh([[:space:]].*)?(pr[[:space:]]+(create|merge)|release[[:space:]]+create)'; then
166
+ has_token ALLOW_PUSH || \
167
+ deny "Leopold guard: opening/merging PRs and creating releases is locked. Report readiness instead."
168
+ fi
169
+ if matches "$norm" '(npm|pnpm|yarn)[[:space:]]+publish|cargo[[:space:]]+publish|twine[[:space:]]+upload|pip[[:space:]].*upload'; then
170
+ has_token ALLOW_PUBLISH || \
171
+ deny "Leopold guard: publishing packages is locked in autonomous mode."
172
+ fi
173
+ ;;
174
+
175
+ Edit|Write|MultiEdit|NotebookEdit)
176
+ path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)"
177
+ case "$path" in
178
+ */GUARDRAILS.md) deny "Leopold guard: GUARDRAILS.md is immutable during an autonomous run." ;;
179
+ */settings.json|*/settings.local.json) deny "Leopold guard: editing Claude Code settings is forbidden in autonomous mode." ;;
180
+ */leopold/hooks/*|*/.leopold/state.json) deny "Leopold guard: the guardrail hooks and run state are immutable during an autonomous run." ;;
181
+ esac
182
+ ;;
183
+ esac
184
+
185
+ exit 0
@@ -0,0 +1,20 @@
1
+ {
2
+ "description": "Leopold hooks: Stop-hook continuity and the PreToolUse git-lock guard. Both are no-ops unless a Leopold run is active in the project.",
3
+ "hooks": {
4
+ "Stop": [
5
+ {
6
+ "hooks": [
7
+ { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-continuity.sh\"" }
8
+ ]
9
+ }
10
+ ],
11
+ "PreToolUse": [
12
+ {
13
+ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
14
+ "hooks": [
15
+ { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-irreversible.sh\"" }
16
+ ]
17
+ }
18
+ ]
19
+ }
20
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # Leopold Stop hook: state-coupled autonomous continuity.
3
+ #
4
+ # When the main agent finishes a turn, this hook reads .leopold/state.json and
5
+ # PLAN.md in the project. If a Leopold run is active and work remains with no
6
+ # stop condition met, it blocks the stop and re-injects a compact "continue"
7
+ # instruction. Otherwise it allows the session to halt.
8
+ #
9
+ # Hook contract (Claude Code Stop): read JSON on stdin. To keep going, print
10
+ # {"decision":"block","reason":"..."} and exit 0. To allow stopping, exit 0 with
11
+ # no output. Failure mode is intentionally fail-open (allow stop): a broken hook
12
+ # must never trap a session in a loop.
13
+
14
+ input="$(cat 2>/dev/null || true)"
15
+
16
+ # jq is required to parse state safely; without it, allow the stop.
17
+ command -v jq >/dev/null 2>&1 || exit 0
18
+
19
+ cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
20
+ [ -z "${cwd:-}" ] && cwd="$PWD"
21
+ LEO="$cwd/.leopold"
22
+ STATE="$LEO/state.json"
23
+
24
+ # Not a Leopold run -> normal stop.
25
+ [ -f "$STATE" ] || exit 0
26
+
27
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '')"
28
+
29
+ # Fail SAFE and LOUD on a broken state file. Malformed JSON, or a missing/non-numeric
30
+ # budget field, means the iteration budget cannot be trusted -- the run could loop
31
+ # forever on a silently-skipped budget. Stop the run and say why, rather than continue
32
+ # blindly or die in silence.
33
+ state_invalid() {
34
+ printf '{"ts":"%s","event":"state_invalid","reason":"%s"}\n' "$now" "$1" >> "$LEO/events.jsonl" 2>/dev/null || true
35
+ echo "Leopold: .leopold/state.json is invalid ($1) -- stopping the run (fail-safe). Fix the file or re-run /leopold-brief." >&2
36
+ printf '{"active":false,"stopped_reason":"state_invalid"}\n' > "$STATE" 2>/dev/null || true
37
+ exit 0
38
+ }
39
+ jq -e . "$STATE" >/dev/null 2>&1 || state_invalid "malformed JSON"
40
+
41
+ active="$(jq -r '.active // false' "$STATE" 2>/dev/null || echo false)"
42
+ [ "$active" = "true" ] || exit 0
43
+
44
+ # A present-but-non-numeric budget field is the real hole: it would make the
45
+ # `iter >= max` test below error out (and get swallowed), silently skipping the
46
+ # budget -> unbounded loop. So any present budget field must be an integer. Missing
47
+ # fields fall back to the safe defaults below (50 / 0 / 3), so the budget still holds.
48
+ for f in iteration max_iterations consecutive_failures max_failures; do
49
+ v="$(jq -r --arg k "$f" '.[$k] // empty' "$STATE" 2>/dev/null)"
50
+ if [ -n "$v" ] && ! printf '%s' "$v" | grep -qE '^[0-9]+$'; then
51
+ state_invalid "non-numeric $f ($v)"
52
+ fi
53
+ done
54
+
55
+ log_event() { printf '%s\n' "$1" >> "$LEO/events.jsonl" 2>/dev/null || true; }
56
+
57
+ allow_stop() {
58
+ local r="$1" tmp
59
+ tmp="$(mktemp 2>/dev/null || echo "$STATE.tmp")"
60
+ jq --arg r "$r" '.active=false | .stopped_reason=$r' "$STATE" > "$tmp" 2>/dev/null && mv "$tmp" "$STATE" || true
61
+ log_event "{\"ts\":\"$now\",\"event\":\"stop\",\"reason\":\"$r\"}"
62
+ # Safety hygiene: always clear the kill switch and per-session git opt-in tokens
63
+ # so the next run re-locks git and does not halt immediately on a stale STOP.
64
+ rm -f "$LEO/STOP" "$LEO/ALLOW_GIT" "$LEO/ALLOW_PUSH" "$LEO/ALLOW_PUBLISH" 2>/dev/null || true
65
+ # on_finish policy (GUARDRAILS.md): archive the run logs on a clean finish.
66
+ if [ "$r" = "plan_complete" ] && grep -qiE '^[[:space:]]*-?[[:space:]]*on_finish:[[:space:]]*archive' "$LEO/GUARDRAILS.md" 2>/dev/null; then
67
+ arch="$LEO/runs/$(date -u +%Y%m%dT%H%M%SZ)"
68
+ mkdir -p "$arch" 2>/dev/null || true
69
+ [ -f "$LEO/DECISIONS.md" ] && mv "$LEO/DECISIONS.md" "$arch/" 2>/dev/null || true
70
+ [ -f "$LEO/events.jsonl" ] && mv "$LEO/events.jsonl" "$arch/" 2>/dev/null || true
71
+ cp "$LEO/PLAN.md" "$arch/" 2>/dev/null || true
72
+ fi
73
+ exit 0
74
+ }
75
+
76
+ # Kill switch.
77
+ if [ -f "$LEO/STOP" ]; then allow_stop "kill_switch"; fi
78
+
79
+ iter="$(jq -r '.iteration // 0' "$STATE" 2>/dev/null || echo 0)"
80
+ max_iter="$(jq -r '.max_iterations // 50' "$STATE" 2>/dev/null || echo 50)"
81
+ fails="$(jq -r '.consecutive_failures // 0' "$STATE" 2>/dev/null || echo 0)"
82
+ max_fails="$(jq -r '.max_failures // 3' "$STATE" 2>/dev/null || echo 3)"
83
+
84
+ # Budgets and repeated failure.
85
+ if [ "$iter" -ge "$max_iter" ] 2>/dev/null; then allow_stop "iteration_budget"; fi
86
+ if [ "$fails" -ge "$max_fails" ] 2>/dev/null; then allow_stop "repeated_failure"; fi
87
+
88
+ # Context budget — the real money pit. A long run accumulates context every turn; on a
89
+ # big-context model it never auto-compacts, so each turn re-bills the whole (growing)
90
+ # transcript and any fork clones it (one report: a session ballooned to ~6MB over 681
91
+ # turns). Stop when the transcript passes max_context_mb (default 5) so it cannot silently
92
+ # balloon. The brief persists -> a fresh /leopold-run resumes from PLAN.md with clean context.
93
+ max_ctx_mb="$(jq -r '.max_context_mb // 5' "$STATE" 2>/dev/null || echo 5)"
94
+ case "$max_ctx_mb" in (*[!0-9]*|"") max_ctx_mb=5 ;; esac
95
+ ctx_mb=0
96
+ tpath="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
97
+ if [ -n "$tpath" ] && [ -f "$tpath" ]; then
98
+ ctx_bytes="$(wc -c < "$tpath" 2>/dev/null || echo 0)"
99
+ ctx_mb="$(awk "BEGIN{printf \"%.1f\", ${ctx_bytes:-0}/1048576}" 2>/dev/null || echo 0)"
100
+ [ "$(( ${ctx_bytes:-0} / 1048576 ))" -ge "$max_ctx_mb" ] 2>/dev/null && allow_stop "context_budget"
101
+ fi
102
+
103
+ # Plan complete? (no unchecked checkboxes remain)
104
+ PLAN="$LEO/PLAN.md"
105
+ open_items="$(grep -cE '^[[:space:]]*- \[ \]' "$PLAN" 2>/dev/null || true)"
106
+ open_items="${open_items:-0}"
107
+ if [ "$open_items" -eq 0 ] 2>/dev/null; then allow_stop "plan_complete"; fi
108
+
109
+ # Loop detection: if the SET of open plan items is byte-identical for N consecutive
110
+ # turns, the run is thrashing without progress (no item checked off, no item added) ->
111
+ # stop and report rather than burn the whole iteration budget hammering one spot.
112
+ max_np="$(jq -r '.max_no_progress // 6' "$STATE" 2>/dev/null || echo 6)"
113
+ case "$max_np" in (*[!0-9]*|"") max_np=6 ;; esac
114
+ sig="$(grep -E '^[[:space:]]*- \[ \]' "$PLAN" 2>/dev/null | cksum | awk '{print $1}')"
115
+ last_sig="$(jq -r '.progress_sig // empty' "$STATE" 2>/dev/null || true)"
116
+ np="$(jq -r '.no_progress // 0' "$STATE" 2>/dev/null || echo 0)"
117
+ case "$np" in (*[!0-9]*|"") np=0 ;; esac
118
+ if [ "$sig" = "$last_sig" ] && [ -n "$last_sig" ]; then np=$((np + 1)); else np=0; fi
119
+ if [ "$np" -ge "$max_np" ] 2>/dev/null; then allow_stop "no_progress"; fi
120
+
121
+ # Otherwise: continue. Increment the iteration counter; persist the progress signature.
122
+ next=$((iter + 1))
123
+ tmp="$(mktemp 2>/dev/null || echo "$STATE.tmp")"
124
+ jq --argjson n "$next" --arg t "$now" --argjson np "$np" --arg sig "$sig" --argjson cm "${ctx_mb:-0}" --arg tp "${tpath:-}" \
125
+ '.iteration=$n | .last_turn=$t | .no_progress=$np | .progress_sig=$sig | .context_mb=$cm
126
+ | (if $tp != "" then .transcript_path=$tp else . end)' "$STATE" > "$tmp" 2>/dev/null && mv "$tmp" "$STATE" || true
127
+ log_event "{\"ts\":\"$now\",\"event\":\"turn_start\",\"iteration\":$next,\"open_items\":$open_items,\"no_progress\":$np}"
128
+
129
+ reason="Leopold autonomous mode is ACTIVE (turn $next/$max_iter, $open_items open plan items). Do not stop. Steps: (1) Read .leopold/PLAN.md and pick the next unchecked item. (2) Complete it; reach for the gstack playbook skill that fits the situation. (3) On any fork, apply .leopold/CHARTER.md and the decision protocol: if the call is reversible OR the charter is clear, decide it yourself, append the decision to .leopold/DECISIONS.md, and keep going; stop only for an irreversible AND ambiguous fork, a charter contradiction, or a mission-premise change. (4) Mark the finished item as done ([x]) in PLAN.md. Hard rules: git commit/push/publish stay locked; never edit files outside this project; never touch .leopold/GUARDRAILS.md or the hooks. When the plan is complete or a stop condition is met, write a short final summary and then stop."
130
+
131
+ jq -cn --arg r "$reason" '{decision:"block", reason:$r}'
132
+ exit 0
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # Leopold installer.
3
+ # Copies the skills into ~/.claude/skills/, the hooks/templates/docs into
4
+ # ~/.claude/leopold/, and wires the Stop + PreToolUse hooks into
5
+ # ~/.claude/settings.json (idempotent, with a backup). The hooks are no-ops
6
+ # unless a Leopold run is active, so they are safe to leave installed.
7
+ set -euo pipefail
8
+
9
+ # Resolve the source tree. When run from a clone, that is the script's dir.
10
+ # When piped (curl ... | bash), there is no local tree, so we fetch one first.
11
+ _self="${BASH_SOURCE[0]:-}"
12
+ if [ -n "$_self" ] && [ -d "$(dirname "$_self")/skills" ]; then
13
+ SRC="$(cd "$(dirname "$_self")" && pwd)"
14
+ else
15
+ SRC="${LEOPOLD_SRC:-$HOME/.local/share/leopold}"
16
+ echo "-> fetching Leopold into $SRC"
17
+ if [ -d "$SRC/.git" ]; then
18
+ ( cd "$SRC" && git pull --ff-only -q ) || true
19
+ else
20
+ mkdir -p "$(dirname "$SRC")"
21
+ git clone --progress --depth 1 https://github.com/Jonhvmp/leopold.git "$SRC"
22
+ fi
23
+ fi
24
+ CLAUDE="${CLAUDE_HOME:-$HOME/.claude}"
25
+ SKILLS="$CLAUDE/skills"
26
+ LEO_HOME="$CLAUDE/leopold"
27
+ SETTINGS="$CLAUDE/settings.json"
28
+
29
+ # Optional gstack integration: pass --with-gstack to install it non-interactively.
30
+ WITH_GSTACK=0
31
+ for _a in "$@"; do [ "$_a" = "--with-gstack" ] && WITH_GSTACK=1; done
32
+
33
+ echo "Leopold installer"
34
+ echo " source: $SRC"
35
+ echo " target: $CLAUDE"
36
+ echo
37
+
38
+ mkdir -p "$SKILLS" "$LEO_HOME"
39
+
40
+ echo "-> installing skills"
41
+ for d in "$SRC"/skills/*/; do
42
+ name="$(basename "$d")"
43
+ rm -rf "${SKILLS:?}/$name"
44
+ cp -R "$d" "$SKILLS/$name"
45
+ echo " $name"
46
+ done
47
+
48
+ echo "-> installing hooks, templates, docs, extensions"
49
+ cp -R "$SRC/hooks" "$LEO_HOME/"
50
+ cp -R "$SRC/templates" "$LEO_HOME/"
51
+ cp -R "$SRC/docs" "$LEO_HOME/" 2>/dev/null || true
52
+ cp -R "$SRC/scripts" "$LEO_HOME/" 2>/dev/null || true
53
+ cp -R "$SRC/extensions" "$LEO_HOME/" 2>/dev/null || true
54
+ cp "$SRC/VERSION" "$LEO_HOME/" 2>/dev/null || true
55
+ chmod +x "$LEO_HOME"/hooks/*.sh
56
+ chmod +x "$LEO_HOME"/scripts/*.sh 2>/dev/null || true
57
+ chmod +x "$LEO_HOME"/extensions/*/manage.sh 2>/dev/null || true
58
+
59
+ STOP_HOOK="$LEO_HOME/hooks/stop-continuity.sh"
60
+ GUARD_HOOK="$LEO_HOME/hooks/guard-irreversible.sh"
61
+
62
+ echo "-> wiring hooks into $SETTINGS"
63
+ if ! command -v jq >/dev/null 2>&1; then
64
+ echo
65
+ echo " jq not found. Add this to $SETTINGS manually:"
66
+ sed "s#~/.claude/leopold#$LEO_HOME#g" "$SRC/settings.template.json"
67
+ echo
68
+ else
69
+ [ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
70
+ cp "$SETTINGS" "$SETTINGS.leopold.bak"
71
+ tmp="$(mktemp)"
72
+ jq --arg stop "$STOP_HOOK" --arg guard "$GUARD_HOOK" '
73
+ .hooks //= {}
74
+ | .hooks.Stop //= []
75
+ | .hooks.PreToolUse //= []
76
+ | (if any(.hooks.Stop[]?.hooks[]?; .command == $stop)
77
+ then . else .hooks.Stop += [{hooks:[{type:"command",command:$stop}]}] end)
78
+ | (if any(.hooks.PreToolUse[]?.hooks[]?; .command == $guard)
79
+ then . else .hooks.PreToolUse += [{matcher:"Bash|Edit|Write|MultiEdit|NotebookEdit",hooks:[{type:"command",command:$guard}]}] end)
80
+ ' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
81
+ echo " merged (backup at $SETTINGS.leopold.bak)"
82
+ fi
83
+
84
+ # Serena — MANDATORY. LSP-backed code intelligence (MCP): symbol-level retrieval/editing
85
+ # instead of grep + whole-file reads. It is the biggest lever for code quality AND for
86
+ # keeping context lean (fewer tokens per operation), so Leopold sets it up for everyone.
87
+ echo
88
+ echo "-> setting up Serena (LSP code intelligence — mandatory for quality + lean context)"
89
+ SERENA_MGR="$LEO_HOME/extensions/serena/manage.sh"
90
+ if [ -f "$SERENA_MGR" ]; then
91
+ bash "$SERENA_MGR" install || echo " Serena setup did not finish; complete it with: make serena-install (or: make menu)"
92
+ else
93
+ echo " (serena extension missing from this build; skipping)"
94
+ fi
95
+
96
+ echo
97
+ GSTACK_DIR="$SKILLS/gstack"
98
+ gstack_present() { [ -d "$GSTACK_DIR" ] || ls "$SKILLS" 2>/dev/null | grep -q '^spec$'; }
99
+ install_gstack() {
100
+ echo "-> installing gstack (MIT, by Garry Tan: https://github.com/garrytan/gstack)"
101
+ command -v bun >/dev/null 2>&1 || echo " note: gstack needs Bun v1.0+ (https://bun.sh); its setup will guide you."
102
+ echo " cloning gstack (shows progress) + running its setup…"
103
+ if git clone --progress --single-branch --depth 1 https://github.com/garrytan/gstack.git "$GSTACK_DIR" && ( cd "$GSTACK_DIR" && ./setup ); then
104
+ echo " gstack installed."
105
+ else
106
+ echo " gstack install did not finish; retry with: make gstack-install"
107
+ fi
108
+ }
109
+
110
+ if gstack_present; then
111
+ echo "gstack detected: Leopold will conduct its planning toolchain (/spec, /autoplan, /plan-*-review, ...)."
112
+ elif [ "$WITH_GSTACK" = "1" ]; then
113
+ install_gstack
114
+ else
115
+ echo "gstack not detected. Leopold works on plain Claude Code, but it shines when it can conduct"
116
+ echo "gstack's planning toolchain (/autoplan, /plan-eng-review, /spec). gstack is a separate MIT"
117
+ echo "project by Garry Tan: https://github.com/garrytan/gstack"
118
+ if [ -t 0 ]; then
119
+ printf "Install gstack now? (clones to %s, runs its setup, needs Bun) [y/N] " "$GSTACK_DIR"
120
+ read -r _ans || _ans=""
121
+ case "$_ans" in [yY]*) install_gstack ;; *) echo "Skipped. Install later: make gstack-install" ;; esac
122
+ else
123
+ echo "Enable it later with: make gstack-install (or re-run ./install.sh --with-gstack)"
124
+ fi
125
+ fi
126
+
127
+ echo
128
+ echo "Done. In any project:"
129
+ echo " /leopold-brief debate the mission, write the brief"
130
+ echo " /leopold-run hand over the seat"
131
+ echo " /leopold-status see where it is"
132
+ echo " /leopold-stop take the seat back"
133
+ echo
134
+ # Offer the toolchain manager. We read from /dev/tty (the controlling terminal),
135
+ # not stdin, so this works even when the installer is piped: `curl ... | bash`
136
+ # leaves stdin as the script, but the terminal is still reachable via /dev/tty.
137
+ MENU="$LEO_HOME/scripts/leopold-menu.sh"
138
+ if exec 3<>/dev/tty 2>/dev/null; then
139
+ printf "Open the toolchain manager (install/manage gstack, ovmem, ...)? [Y/n] " >&3
140
+ read -r _ans <&3 || _ans="y"
141
+ case "$_ans" in
142
+ [nN]*) echo "Skipped. Open it anytime: bash $MENU (or: make menu)" ;;
143
+ *) bash "$MENU" <&3 || true ;;
144
+ esac
145
+ exec 3>&-
146
+ else
147
+ # no terminal (headless / CI): just point at it
148
+ echo "Manage the toolchain (gstack, ovmem, ...):"
149
+ echo " bash $MENU (or: make menu)"
150
+ fi