diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cae306e..63b1a0b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to SkillOpt are documented here. This project adheres to ## [Unreleased] ### Added +- A non-destructive Devin installer and SessionEnd activity marker, preserving + existing project hooks across repeated installation. - Per-night SkillOpt-Sleep `evidence.jsonl` chains for reconstructing harvest, mining, replay, reflection, and gate decisions, plus a live prompt-template registry with user overrides. diff --git a/plugins/devin/README.md b/plugins/devin/README.md index b5417b6e..53abe6e8 100644 --- a/plugins/devin/README.md +++ b/plugins/devin/README.md @@ -18,7 +18,7 @@ source into the Claude Code-compatible JSONL the engine reads. | `mcp-config.example.json` | drop-in MCP server config | | `install.sh` | copies hooks + rules into a project's `.devin/` and prints the MCP registration command | | `devin-rules.snippet.md` | copied to `.devin/rules/skillopt-sleep.md` by `install.sh` | -| `hooks/hooks.v1.json` | SessionEnd hook config — copied to `.devin/hooks.v1.json` by `install.sh` | +| `hooks/hooks.v1.json` | SessionEnd hook config — installed/merged at `.devin/hooks.v1.json` by `install.sh` | | `hooks/on-session-end.sh` | best-effort activity marker script (called by the hook) | ## What it harvests @@ -44,9 +44,11 @@ Requires Python ≥ 3.10. No third-party packages — the server is pure stdlib. This copies the SessionEnd hook and rules snippet into the project's `.devin/` directory and prints the MCP registration command. The hook is - on by default — it logs a cheap activity marker when each session ends so - the next nightly cycle knows there is fresh data to harvest. It is - non-blocking and spends no API budget. Re-run the script to update. + on by default — it logs a cheap activity marker for local inspection or + external automation when each session ends. The current engine harvests by + transcript timestamps and does not consume this marker directly. The hook + is non-blocking and spends no API budget. Re-run the script to update; the + installer preserves existing hooks and does not duplicate its own entry. 2. **Register the MCP server.** Use `mcp-config.example.json` as a template, or run the command printed by `install.sh`: diff --git a/plugins/devin/hooks/hooks.v1.json b/plugins/devin/hooks/hooks.v1.json index 5bca19f6..6af99d18 100644 --- a/plugins/devin/hooks/hooks.v1.json +++ b/plugins/devin/hooks/hooks.v1.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"${DEVIN_PROJECT_DIR}/.devin/hooks/on-session-end.sh\"", + "command": "\"${DEVIN_PROJECT_DIR}/.devin/hooks/skillopt-sleep-on-session-end.sh\"", "timeout": 5 } ] diff --git a/plugins/devin/hooks/on-session-end.sh b/plugins/devin/hooks/on-session-end.sh index 1f886d98..dd0b0ec7 100755 --- a/plugins/devin/hooks/on-session-end.sh +++ b/plugins/devin/hooks/on-session-end.sh @@ -1,19 +1,20 @@ #!/usr/bin/env bash # SkillOpt-Sleep SessionEnd hook for Devin (best-effort, NON-BLOCKING). # -# This does NOT run the optimizer. It only appends a tiny marker so the next -# nightly cycle knows there is fresh activity to harvest, and (optionally) -# nudges the user once that a sleep cycle is available. It must never fail the -# session or spend API budget. +# This does NOT run the optimizer. It only appends a tiny marker for local +# inspection or external automation. The current sleep engine uses transcript +# timestamps rather than this marker. The hook must never fail the session or +# spend API budget. # -# Install: copy this file and hooks.v1.json into your project's .devin/hooks/ -# directory. Devin CLI reads .devin/hooks.v1.json automatically. +# Install this script as .devin/hooks/skillopt-sleep-on-session-end.sh and the +# config at .devin/hooks.v1.json. Devin CLI reads it automatically. set -uo pipefail +[ -n "${HOME:-}" ] || exit 0 STATE_DIR="${HOME}/.skillopt-sleep" mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 -# Record that a session just ended (cheap; used for "is there new data?"). +# Record that a session just ended (cheap local activity signal). printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${DEVIN_PROJECT_DIR:-${PWD}}" \ >> "$STATE_DIR/session-end.log" 2>/dev/null || true diff --git a/plugins/devin/install.sh b/plugins/devin/install.sh index 79ab0fdb..32718484 100755 --- a/plugins/devin/install.sh +++ b/plugins/devin/install.sh @@ -18,24 +18,61 @@ mkdir -p "$DEVIN_DIR/hooks" "$DEVIN_DIR/rules" # Merge into existing hooks.v1.json instead of overwriting, so we don't # destroy other project hooks. HOOK_SCRIPT_SRC="$PLUGIN_DIR/hooks/on-session-end.sh" -HOOK_SCRIPT_DST="$DEVIN_DIR/hooks/on-session-end.sh" +HOOK_SCRIPT_DST="$DEVIN_DIR/hooks/skillopt-sleep-on-session-end.sh" cp "$HOOK_SCRIPT_SRC" "$HOOK_SCRIPT_DST" chmod +x "$HOOK_SCRIPT_DST" echo "[install] hook script -> $HOOK_SCRIPT_DST" HOOK_CONFIG="$DEVIN_DIR/hooks.v1.json" if [ -f "$HOOK_CONFIG" ]; then - # Merge our SessionEnd hook into the existing config (jq deep-merge) - if command -v jq >/dev/null 2>&1; then - jq -s '.[0] * .[1]' "$HOOK_CONFIG" "$PLUGIN_DIR/hooks/hooks.v1.json" > "$HOOK_CONFIG.tmp" - mv "$HOOK_CONFIG.tmp" "$HOOK_CONFIG" - echo "[install] session-end hook -> $HOOK_CONFIG (merged)" - else - echo "[install] WARNING: jq not found; cannot merge into existing $HOOK_CONFIG" - echo "[install] Merge this SessionEnd hook manually or install jq:" - echo "[install] cat $PLUGIN_DIR/hooks/hooks.v1.json" - echo "[install] Skipping hook config to avoid overwriting existing hooks." - fi + # Python is already required by the plugin. Merge event arrays without + # replacing existing hooks, and skip exact duplicates on repeated installs. + python3 - "$HOOK_CONFIG" "$PLUGIN_DIR/hooks/hooks.v1.json" <<'PY' +import json +import os +import stat +import sys +import tempfile + +destination, addition = sys.argv[1:] + + +def load_object(path): + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError(f"hook config must be a JSON object: {path}") + return value + + +base = load_object(destination) +incoming = load_object(addition) +for event, entries in incoming.items(): + if not isinstance(entries, list): + raise ValueError(f"hook event {event!r} must be an array") + existing = base.setdefault(event, []) + if not isinstance(existing, list): + raise ValueError(f"existing hook event {event!r} must be an array") + for entry in entries: + if entry not in existing: + existing.append(entry) + +directory = os.path.dirname(os.path.abspath(destination)) +fd, temporary = tempfile.mkstemp(prefix=".hooks.v1.", suffix=".tmp", dir=directory) +try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(base, handle, ensure_ascii=False, indent=2) + handle.write("\n") + os.chmod(temporary, stat.S_IMODE(os.stat(destination).st_mode)) + os.replace(temporary, destination) +except Exception: + try: + os.unlink(temporary) + except OSError: + pass + raise +PY + echo "[install] session-end hook -> $HOOK_CONFIG (merged)" else cp "$PLUGIN_DIR/hooks/hooks.v1.json" "$HOOK_CONFIG" echo "[install] session-end hook -> $HOOK_CONFIG" @@ -46,13 +83,14 @@ cp "$PLUGIN_DIR/devin-rules.snippet.md" "$DEVIN_DIR/rules/skillopt-sleep.md" echo "[install] rules snippet -> $DEVIN_DIR/rules/skillopt-sleep.md" # 3) Print the MCP server registration command +printf -v MCP_SERVER_QUOTED '%q' "$PLUGIN_DIR/mcp_server.py" cat <