[Logging]
Structured JSONL logging for all hook commands and the status line. Off by default — hooks run silently unless --log or --log-level is set. Parse errors are always recorded, regardless of flags.
// 01Overview
When enabled, log lines are appended to date-partitioned files at ~/.scut/logging/. Logging is off by default — hooks run silently unless a flag is set.
The command surface is scut claude --log and scut claude --log-level=LEVEL. Both flags live on claude.Cmd so they propagate uniformly to all hook subcommands and to status-line.
When neither flag is set, all Run() methods receive logging.Discard — a no-op logger backed by io.Discard. No file is opened, no disk I/O occurs.
// 02Flags
Flags live on claude.Cmd so they propagate to both hook subcommands and status-line.
| Flag | Type | Default | Behavior |
|---|---|---|---|
| --log | bool | false | Enable logging at info level |
| --log-level | string | — | Set log level (debug, info, warn, error); implies --log |
Info is the default because the hook/status-line "happy path" logs at info; a warn default would produce empty files in normal operation. Use --log-level=debug to capture full status-line input payloads (model ID, context window size, workspace dir).
Parse-error logging (unconditional)
main() wraps kong parsing in two steps — kong.Must() then parser.Parse(os.Args[1:]) — so we can intercept parse failures before kong calls os.Exit. When parsing fails, logging.LogParseError(os.Args, err) appends a record to ~/.scut/logging/YYYYMMDD_parse-errors.jsonl capturing the full os.Args (including argv[0]) and the kong error message.
--log / --log-level flags have been parsed out of the args anyway.When parsing succeeds, main() emits a logger.Debug("invoked", "args", os.Args, "command", ctx.Command()) record so the success path can be compared against failures.
// 03File Layout
~/.scut/logging/ 20260403_post-tool-use.jsonl ← today's post-tool-use log 20260403_status-line.jsonl ← today's status-line log 20260403_parse-errors.jsonl ← kong parse failures (written unconditionally) 20260402_post-tool-use.jsonl ← yesterday's log 20260401_session-start.jsonl.1712000000 ← rotated file
- Filename:
YYYYMMDD_<command-name>.jsonlwhere command-name is the leaf of the Kong command path (e.g.,post-tool-use,status-line,session-start). - Format: JSONL — one JSON object per line, produced by
slog.NewJSONHandler. - Rotation: On open, if the file exceeds 10 MB it is renamed with a unix-second suffix (e.g.,
.jsonl.1712345678) and a fresh file is created. Rotation happens at most once per process invocation. - Write mode:
O_APPEND|O_CREATE|O_WRONLY— the file is never read into memory. POSIX guarantees atomicity for small writes, so concurrent hook invocations writing to the same file don't corrupt each other.
// 04Architecture
Wiring
The logger is created in main() after parsing but before Run():
ctx, err := parser.Parse(os.Args[1:]) if err != nil { logging.LogParseError(os.Args, err) parser.FatalIfErrorf(err) } logger, logCloser := c.Claude.OpenLogger(ctx.Command()) if logCloser != nil { defer logCloser.Close() } logger.Debug("invoked", "args", os.Args, "command", ctx.Command()) ctx.FatalIfErrorf(ctx.Run(logger))
ctx.Run(logger) passes the *slog.Logger as an extra binding. Kong injects it into any Run() method that declares a *slog.Logger parameter.
OpenLogger
claude.Cmd.OpenLogger(command string) checks the --log and --log-level flags. If logging is disabled, it returns logging.Discard and a nil closer. If enabled, it calls logging.Open(name, level) which:
- Resolves
~/.scut/logging/(creating it if needed) - Computes the filename from today's date and the command name
- Rotates the existing file if it exceeds 10 MB
- Opens the file in append mode
- Returns a
*slog.Loggerbacked byslog.NewJSONHandler
The command name is extracted from the Kong command path: "claude hook post-tool-use" → "post-tool-use", "claude status-line" → "status-line".
Run method signatures
All hook commands and the status line accept *slog.Logger as a Run() parameter:
// Hook commands (most): func (c *sessionStartCmd) Run(stdin io.Reader, stdout io.Writer, logger *slog.Logger) error // PostToolUse (also needs afero.Fs): func (c *postToolUseCmd) Run(stdin io.Reader, stdout io.Writer, fs afero.Fs, logger *slog.Logger) error // Status line: func (c *statusLineCmd) Run(stdin io.Reader, stdout io.Writer, logger *slog.Logger) error
In tests, pass logging.Discard as the logger:
cmd := &postToolUseCmd{}
err := cmd.Run(stdin, &stdout, fs, logging.Discard)
// 05Standardized Log Fields
Every log line includes these fields (via slog.JSONHandler):
| Field | Source | Description |
|---|---|---|
| time | slog | ISO 8601 timestamp |
| level | slog | INFO, WARN, ERROR, DEBUG |
| msg | handler | Action taken: "handled", "formatted", "rendered", "skipped" |
| hook | handler | Hook/command name: "post-tool-use", "status-line", etc. |
| session_id | input | Claude Code session identifier |
| duration_ms | handler | Wall-clock milliseconds for the handler |
Hook-specific fields
| Hook | Extra Fields |
|---|---|
| session-start | source (startup, resume, clear, compact) |
| session-end | reason (clear, resume, logout, etc.) |
| pre-tool-use | tool_name |
| post-tool-use | tool_name, file_path, formatter, reason (when skipped) |
| stop-failure | error (rate_limit, server_error, etc.) |
| pre-compact, post-compact | trigger (manual, auto) |
| status-line | model (raw ID), path, branch, context_pct; at debug: model_display_name, context_window_size, exceeds_200k_tokens, cwd, workspace_current_dir |
| parse-errors | args (full os.Args), argc, error (kong error message); written unconditionally on parse failure regardless of --log flag |
| (every command, at debug) | "invoked" record with args (full os.Args) and command (resolved kong command path); emitted from main() before dispatch |
os.Args are recorded on every invocation at debug level, and status-line debug records include workspace paths and model identifiers.// 06Cleanup
scut logging clean removes old log files.
| Flag | Default | Behavior |
|---|---|---|
| --all | false | Remove all .jsonl files regardless of age |
| --days N | 7 | Remove files with mtime older than N days |
The command scans ~/.scut/logging/ and removes files with a .jsonl extension (including rotated files like .jsonl.1712345678). Non-.jsonl files are left untouched.
--all removes every .jsonl file in the log directory with no age filter — this is irreversible.// 07Code
| Role | Path |
|---|---|
| Core package | internal/logging/logging.go |
Open, Discard, rotation logic |
internal/logging/logging.go |
Flags + OpenLogger |
internal/cmd/claude/claude.go |
| Clean command | internal/cmd/logging/logging.go |
| Main wiring | cmd/scut/main.go |
See also: kong-base-setup for how the logger is wired via ctx.Run(logger) and how the two-step parse pattern enables unconditional parse-error capture.