SLOPSHOPPER

skogai-core

Acts upon Claude Code hook events with one script per event, logs each one to log.jsonl, journals and stages every turn, and shows the last logged event in the…

newstatusprocess
v0.7.0no licenseupdated 2026-10-16skogai2/marketplace/plugins/skogai-core
A shopper browsing a rack in a slop shop
README

skogai-core

A Claude Code plugin for acting upon hook events. Each event we act upon has its own script, every action is logged, and the status line shows the event being acted upon. What it does and why is in FEATURES.md:

  • Event log: one JSON row per event we act upon in .skogai/logs/skogai-core/log.jsonl at the git root.
  • Diff as handover: on Stop, appends the turn (prompt, reply, tool uses) to .skogai/journal.md at the git root and runs git add -A. Nothing is committed automatically.
  • Status line: shows hook: <event> from the log's last row.

Installing it

/plugin install skogai-core --marketplace skogai2/marketplace

Running it

cd ~/marketplace
claude --plugin-dir plugins/skogai-core

The scripts need bash, jq, git and flock. Check the plugin without a session:

claude plugin validate plugins/skogai-core

Test the scripts without a session (needs nothing but the tools above):

plugins/skogai-core/tests/run.sh

Tail the log while you use Claude:

tail -f .skogai/logs/skogai-core/log.jsonl | jq -c '{event, action}'

Settings

Per project, in .claude/skogai-core.local.md at the git root (gitignored). Start from the example and check it:

cp plugins/skogai-core/examples/skogai-core.local.md .claude/
plugins/skogai-core/scripts/validate-settings.sh
KeyDefaultWhat it does
enabledtruefalse switches everything off: log, journal, git steps, status line
eventsallHook events to log, as a list; globs such as "Post*" work. The journal needs UserPromptSubmit and PostToolUse*
log_path.skogai/logs/skogai-core/log.jsonlWhere the log goes, relative to the git root
status_linetruefalse hides hook: <event>

Scripts read the file on every event and so does the status line, so edits apply without a restart. The git-step switches stay env vars (see diff as context).

Files

.claude-plugin/plugin.json   manifest
hooks/hooks.json             the events we act upon, plus the status-line module
hooks/register.ts            the status-line mod: shows the log's last event
scripts/events/<Event>.sh    one script per event, run with the event's JSON on stdin
scripts/append-json.sh       appends one row to the log: <json> | append-json.sh [action]
scripts/settings.sh          sourced by the scripts: reads .claude/skogai-core.local.md
scripts/validate-settings.sh checks a settings file: validate-settings.sh [path]
examples/skogai-core.local.md  settings to copy into a project
tests/run.sh                 tests the scripts in a throwaway git repo

Acting upon another event

  1. Add the event to hooks/hooks.json, pointing at scripts/events/<Event>.sh. The events are listed in the hooks reference.
  2. Write the script. Do the work, then pipe the input to append-json.sh with a short description of what you did. A script that only logs is one line:
   exec "$(dirname "$0")/../append-json.sh"

A script can also answer back the way any command hook can, with exit code 2 or JSON on stdout.

Learnings

See LEARNINGS.md.

Source 1 files
hooks/register.ts 49 lines
1import type { Register } from 'claude-code'
2
3// The plain hooks in hooks.json do the acting, and each one writes a row to
4// the log with scripts/append-json.sh. This module only shows the event of
5// the log's last row in the status line.
6const LOG = '.skogai/logs/skogai-core/log.jsonl'
7// Per-project settings, read the same way as scripts/settings.sh.
8const SETTINGS = '.claude/skogai-core.local.md'
9
10// The git root, found once per load. The scripts resolve the log against it
11// too, so both agree wherever the session's working directory is.
12let root: string | null = null
13
14// One scalar from the settings file's frontmatter, unquoted, or undefined.
15function setting(text: string, key: string): string | undefined {
16  const lines = text.split('\n')
17  if (lines[0] !== '---') return undefined
18  for (const line of lines.slice(1)) {
19    if (line === '---') return undefined
20    if (line.startsWith(key + ':')) {
21      return line.slice(key.length + 1).replace(/\s+#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2')
22    }
23  }
24  return undefined
25}
26
27export const register: Register = on => {
28  // classic.* matches every settings hook event (Stop, PreToolUse, ...).
29  on('classic.*', async ($, e, next) => {
30    // Let the plain hooks run, and write their row, before reading the log.
31    const result = await next(e)
32    try {
33      if (root === null) {
34        const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
35        root = git.exitCode === 0 ? git.stdout.trim() : '.'
36      }
37      // Read on every event, so edits to the settings apply without a reload.
38      const path = `${root}/${SETTINGS}`
39      const text = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
40      if (setting(text, 'enabled') === 'false' || setting(text, 'status_line') === 'false') return result
41      // tail reads only the last row, so the log can grow past $.fs.read's 4 MiB.
42      const last = await $.process.run(['tail', '-n1', `${root}/${setting(text, 'log_path') || LOG}`])
43      const event = JSON.parse(last.stdout).event
44      if (typeof event === 'string') $.ui.status('hook: ' + event)
45    } catch {}
46    return result
47  })
48}
49