Repository navigation
feat(hooklife): auto-activation hook (SessionStart + UserPrompt) for per-session rule injection (caveman activate-mode analog) #176
Description
Summary
Add a per-session auto-activation hook to SIN-Code's internal/hooklife/ subsystem that injects a chosen rule block (verbosity mode, project profile, skill activation) at the start of every session, without requiring the user to type /terse or similar. Inspired by JuliusBrussee/caveman's SessionStart hook (src/hooks/caveman-activate.js):
Runs once per Claude Code session start. Three things:
- Writes the active mode to
$CLAUDE_CONFIG_DIR/.caveman-activeviasafeWriteFlag(creates if missing)- Emits caveman ruleset as hidden stdout — Claude Code injects SessionStart hook stdout as system context, invisible to user
- Checks
settings.jsonfor statusline config; if missing, appends nudge to offer setup on first interactionSilent-fails on all filesystem errors — never blocks session start.
The hook reads a config file at ~/.config/sin-code/auto-activate.json (or per-project .sin-code/auto-activate.json) and injects the chosen rule block on every session start.
Motivation
Caveman's SessionStart hook is the engine of the "always-on" UX. Without it, the user has to type /caveman every session. With it, the rule is silently active from message one. The README frames it as the "no nag" guarantee:
Lobster terse from message one. No
/cavemanper session. No nag.
The mechanism is flag-file + hook + system-prompt injection:
- A flag file at
$CLAUDE_CONFIG_DIR/.caveman-activeis written by the hook. - The hook itself emits the rule body to stdout, which Claude Code injects as system context.
- The
UserPromptSubmithook reads the flag and re-injects the rule per turn (per-turn reinforcement).
For SIN-Code, the equivalent is:
- A flag file at
~/.config/sin-code/active-rules.jsonis written when a rule is activated. - The
hooklife.SessionStarthook reads the flag and emits the rule body to itsDecision.Output, which the agent loop prepends to the system prompt. - The
hooklife.PreTurnhook (analogous toUserPromptSubmit) re-injects the rule per turn.
The user activates a rule with sin-code rules enable <name>, and the rule is active for all subsequent sessions. They deactivate with sin-code rules disable <name>. No nag, no per-session invocation.
Current State in SIN-Code
| Area | File | Lines | What is there |
|---|---|---|---|
| hooklife package | cmd/sin-code/internal/hooklife/ |
— | 7 files: builtin.go (183), cli.go, doc.go, event.go, registry.go (46), runner.go. |
| Built-in hooks | cmd/sin-code/internal/hooklife/builtin.go |
1–183 | BlockNoVerify, ConfigProtection, PostEditFormat, PostEditTypecheck, QualityGate, CostTracker, SuggestCompact. No AutoActivate hook. |
| Hook registry | cmd/sin-code/internal/hooklife/registry.go |
1–46 | Registry.Register, Hooks(p), All(). |
| Hook events | cmd/sin-code/internal/hooklife/event.go |
— | Phase enum: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd, PreCompact, UserPrompt. |
| SessionStart hook usage | — | — | The SessionStart phase is defined but no built-in hook registers for it. |
| UserPrompt hook usage | — | — | The UserPrompt phase is defined but no built-in hook registers for it. |
| Config | cmd/sin-code/internal/config.go |
25–43 | No rules.auto_activate field. |
| Learning bridge | cmd/sin-code/internal/learning/learner.go |
13 | Learner.BeforeTurn injects the instinct block. This is the existing per-turn reinforcement point. |
| Adapter | cmd/sin-code/internal/adapters/ |
— | Concrete adapters that implement the abstract hooklife.Verifier, instinct.MemorySink, and instinct.Completer interfaces. |
What this tells us: SIN-Code has the hooklife infrastructure (registry, runner, phases, built-ins). It does not have a SessionStart hook that emits a rule body to stdout, nor a PreTurn hook that re-injects. Both are well-bounded additions to the existing package.
What caveman does (precise)
From https://github.com/JuliusBrussee/caveman/blob/main/CLAUDE.md:
src/hooks/caveman-activate.js— SessionStart hookRuns once per Claude Code session start. Three things:
- Writes the active mode to
$CLAUDE_CONFIG_DIR/.caveman-activeviasafeWriteFlag(creates if missing)- Emits caveman ruleset as hidden stdout — Claude Code injects SessionStart hook stdout as system context, invisible to user
- Checks
settings.jsonfor statusline config; if missing, appends nudge to offer setup on first interactionSilent-fails on all filesystem errors — never blocks session start.
And:
src/hooks/caveman-mode-tracker.js— UserPromptSubmit hookReads JSON from stdin. Three responsibilities:
Slash-command activation. If prompt starts with
/caveman, writes mode to flag file viasafeWriteFlag:
/caveman→ configured default (seecaveman-config.js, defaults tofull)/caveman lite→lite/caveman ultra→ultra
...Natural-language activation/deactivation. Matches phrases like "activate caveman", "turn on caveman mode", ...
Per-turn reinforcement. When flag is set to a non-independent mode (i.e. not
commit/review/compress), emits a smallhookSpecificOutputJSON reminder so the model keeps caveman style after other plugins inject competing instructions mid-conversation. The full ruleset still comes from SessionStart — this is just an attention anchor.
The three components are:
- Flag file:
safeWriteFlagfor symlink-safe writes. Refuses if flag target or its immediate parent is a symlink. Atomic temp + rename. Creates with0600. Silent-fails on all errors. - SessionStart emission: writes the ruleset to stdout; the agent loader prepends it to the system prompt.
- PreTurn re-injection: a small attention anchor per turn.
Detailed Implementation Plan
Phase 1 — Flag file and config
-
Add
cmd/sin-code/internal/hooklife/autoactivate/flag.go(new subpackage) with:type Flag struct { Version int `json:"version"` Rules map[string]RuleActivation `json:"rules"` } type RuleActivation struct { Level string `json:"level"` // e.g. "terse" | "ultra" | "skill:sin-instinct" ActivatedAt time.Time `json:"activated_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } func DefaultPath() string // ~/.config/sin-code/active-rules.json func Read() (*Flag, error) func Write(f *Flag) error // safeWriteFlag-style: symlink-refuse, atomic func AddRule(level string) error func RemoveRule(level string) error
-
Add
cmd/sin-code/internal/hooklife/autoactivate/safe_write.gowithsafeWrite:os.OpenFilewithO_NOFOLLOWwhere supported.- Refuses if the flag file or its parent is a symlink.
- Atomic temp + rename.
0600mode.- Silent-fail on all errors (return the error but never panic).
-
Add config keys in
cmd/sin-code/internal/config.go:25-43:RulesAutoActivate []string `toml:"rules.auto_activate"` // default: [] RulesAutoActivateOn []string `toml:"rules.auto_activate_on"` // events: "session_start" | "pre_turn" RulesAutoDeactivateOn []string `toml:"rules.auto_deactivate_on"`
Phase 2 — SessionStart hook
-
Add
cmd/sin-code/internal/hooklife/autoactivate/hook.gowith:type SessionStartHook struct{} func (SessionStartHook) ID() string { return "auto-activate-session-start" } func (SessionStartHook) Phases() []Phase { return []Phase{SessionStart} } func (SessionStartHook) Run(ctx context.Context, ev Event) Decision { flag, err := Read() if err != nil || flag == nil || len(flag.Rules) == 0 { return Decision{Verdict: Allow} // silent-fail } var body strings.Builder for level := range flag.Rules { body.WriteString(renderRuleBody(level)) body.WriteString("\n\n") } return Decision{ Verdict: Allow, Output: body.String(), // agent loop prepends to system prompt } }
-
renderRuleBodylooks up the level:- If level is
terse/ultra/verbose/normal, callstyle.RenderSystemBlock(level)(issue feat(agent): add verbosity/compression mode (terse/ultra) with system-prompt injection #167). - If level is
skill:<name>, look up the bundled skill inskills/flatfs.go:1-215and return the SKILL.md body. - If level is
profile, returndocs/agent-profiles/sin-profile.md(issue feat(profile): single-source-of-truth sin-profile.md + per-agent renderers (Claude/Codex/Gemini/Cursor/Windsurf/Cline/Copilot) + CI sync #175). - Unknown level: empty string + warning log.
- If level is
Phase 3 — PreTurn hook
-
Add
PreTurnHook:type PreTurnHook struct{} func (PreTurnHook) ID() string { return "auto-activate-pre-turn" } func (PreTurnHook) Phases() []Phase { return []Phase{UserPrompt} } func (PreTurnHook) Run(ctx context.Context, ev Event) Decision { flag, err := Read() if err != nil || flag == nil || len(flag.Rules) == 0 { return Decision{Verdict: Allow} } // Per-turn attention anchor. The full ruleset is in SessionStart; // this is just a reminder so the model doesn't drift. var body strings.Builder body.WriteString("# Auto-activated rules\n") body.WriteString("Apply these rules for the rest of the session:\n") for level := range flag.Rules { body.WriteString("- ") body.WriteString(level) body.WriteString("\n") } return Decision{Verdict: Allow, Output: body.String()} }
-
Wire both hooks into the
Registryat startup. This is the "always-on" path. Theloopbuilder(perAGENTS.md §4) instantiates theRegistry; we add the two new hooks alongside the existing built-ins.
Phase 4 — CLI
-
Add
sin-code rulessubcommand incmd/sin-code/rules_cmd.go:sin-code rules list— show the active rules.sin-code rules enable <level>— add a rule.sin-code rules disable <level>— remove a rule.sin-code rules enable terse— terse mode (issue feat(agent): add verbosity/compression mode (terse/ultra) with system-prompt injection #167).sin-code rules enable skill:sin-instinct— auto-inject sin-instinct.sin-code rules enable profile— auto-inject the per-project profile.sin-code rules --expires-in 1h terse— temporary activation (setsExpiresAt).
-
Add a
sin-code rules preview <level>command that prints the rule body without activating it. This is the "what would the model see" view. -
Add the slash commands in the chat REPL:
/rules— show the active rules./terse//verbose— already in issue feat(agent): add verbosity/compression mode (terse/ultra) with system-prompt injection #167; here we also callsin-code rules enable terse(ordisable).
Phase 5 — Tests
-
Unit test for
safeWrite:- Refuses to overwrite a symlink.
- Atomic on crash.
- Mode is
0600.
-
Unit test for
Read/Write/AddRule/RemoveRule:- Round-trip preserves the flag.
AddRuleis idempotent (does not duplicate).RemoveRuleremoves the key.
-
Unit test for
SessionStartHook.Run:- Empty flag →
Allow, emptyOutput. - With
terserule →Outputcontains the terse ruleset. - Unknown level →
Allow, emptyOutput(silent-fail).
- Empty flag →
-
Unit test for
PreTurnHook.Run:- Empty flag →
Allow, emptyOutput. - With
terserule →Outputcontains the attention-anchor line.
- Empty flag →
-
Integration test for
sin-code rules:enable tersewrites the flag.listshows it.disable terseremoves it.preview terseprints the body without modifying.
-
Race test:
go test -race -count=1 ./cmd/sin-code/internal/hooklife/...must pass.
Phase 6 — Docs
docs/auto-activate.md: the flag file, the hooks, the CLI, the lifetime.- Update
AGENTS.md§14.2 (hooks) to reference the new hooks. - Update
README.mdto mentionsin-code rules. - Update
CHANGELOG.mdUnreleased section.
Acceptance Criteria
-
cmd/sin-code/internal/hooklife/autoactivate/package exists with the flag file, the SessionStart hook, and the PreTurn hook. -
safeWriterefuses symlinks and is atomic. -
sin-code rules enable|disable|list|previewworks. - The
SessionStarthook emits the rule body toDecision.Outputwhen the flag has rules. - The
UserPrompthook emits the attention-anchor per turn. - The hooks silent-fail on all filesystem errors (no panic, no block).
-
docs/auto-activate.mdexists. - All unit and integration tests pass with
-race. -
golangci-lint,govulncheck,gosec(SARIF) green.
Risk and Rollback
- Risk: the rule body bloats the system prompt. Mitigation: the rule body is opt-in (
sin-code rules enable). The user is in control. The default config hasrules.auto_activate = []. - Risk: the per-turn attention anchor competes with the user's own system prompt. Mitigation: the anchor is a small block (~50 chars for one rule); it is below the typical system-prompt size.
- Risk: the flag file is owned by the user; another process could clobber it. Mitigation:
safeWriterefuses symlinks, uses0600, and writes atomically. A racing process can still corrupt the file, but the corruption is visible (JSON parse fails) and the hook falls back toAllowwith emptyOutput. - Rollback: revert the PR. The flag file is a new artifact; deleting it is harmless. The hooks are new code paths; the existing built-ins are unchanged.
References
- Caveman SessionStart hook: https://github.com/JuliusBrussee/caveman/blob/main/CLAUDE.md
- Caveman mode-tracker hook: https://github.com/JuliusBrussee/caveman/blob/main/CLAUDE.md
- Caveman safeWriteFlag: https://github.com/JuliusBrussee/caveman/blob/main/CLAUDE.md
- SIN-Code hooklife builtins:
cmd/sin-code/internal/hooklife/builtin.go:1-183 - SIN-Code hooklife registry:
cmd/sin-code/internal/hooklife/registry.go:1-46 - SIN-Code learning bridge:
cmd/sin-code/internal/learning/learner.go:13 - SIN-Code adapters:
cmd/sin-code/internal/adapters/ - SIN-Code config:
cmd/sin-code/internal/config.go:25-43 - SIN-Code verbosity mode: issue feat(agent): add verbosity/compression mode (terse/ultra) with system-prompt injection #167
- SIN-Code agent profiles: issue feat(profile): single-source-of-truth sin-profile.md + per-agent renderers (Claude/Codex/Gemini/Cursor/Windsurf/Cline/Copilot) + CI sync #175
- SIN-Code AGENTS.md §14.2 (hooks), §9 (test discipline)
Activity
Delqhi commentedon Jun 16, 2026
Ergänzung:
ponytail-Inspiration (v4.7.0)Hook-Architektur 1:1 übernehmbar:
ponytail hat exakt die gleiche Hook-Architektur wie caveman, aber für den Lazy-Skill statt den Caveman-Skill. Das ist ein direkter Verifier für unseren Plan in diesem Issue.
ponytail's Hook-Layer (
hooks/):ponytail-activate.js– SessionStart: schreibt Flag-File, emittiert Ruleset zu stdoutponytail-mode-tracker.js– UserPromptSubmit: liest Flag, erkennt/ponytailSlash-Command, schreibt neuen Modeponytail-instructions.js– geteiltes Instruction-Builder-Modul (CommonJS, von Claude/Codex/Pi/OpenCode geteilt)ponytail-config.js– Config-Resolution (env > file > default)Wir übernehmen die Architektur wörtlich:
cmd/sin-code/internal/hooklife/autoactivate/(Phase 1 dieses Issues) wird:flag.go(Phase 1, Schritt 1) – entspricht ponytail's Flag-Filesafe_write.go(Phase 1, Schritt 2) – entspricht ponytail'ssafeWriteFlag(symlink-refuse, atomic, 0600)hook_session_start.go(Phase 2, Schritt 4) – entspricht ponytail'sponytail-activate.jshook_pre_turn.go(Phase 3, Schritt 6) – entspricht ponytail'sponytail-mode-tracker.jsinstructions.go(NEU) – entspricht ponytail'sponytail-instructions.js(CommonJS → Go-Äquivalent)config.go(NEU) – entspricht ponytail'sponytail-config.jsgetPonytailInstructions(mode)-Pattern in Go:ponytail's
hooks/ponytail-instructions.js:1-95zeigt diegetXxxInstructions(mode)-Funktion mit:normalizeMode,normalizePersistedMode)review, der komplett andere Regeln hat)In Go für unseren
autoactivate-Hook:Config-Resolution-Order (ponytail-CLAUDE.md:1-10):
Wir übernehmen das wörtlich für
SIN_CODE_DEFAULT_RULE:SIN_CODE_DEFAULT_RULEenv~/.config/sin-code/auto-activate.json(oder per-project.sin-code/auto-activate.json)''(leer = keine Auto-Activation)Ponytail-Levels vs. SIN-Code-Levels:
ponytail:
off | lite | full | ultra | reviewSIN-Code:
off | lazy-lite | lazy-full | lazy-ultraPlus unsere eigenen:
verbosity: terse | ultra(aus #167),profile(aus #175)Die Auto-Activation muss diese drei Achsen kombinieren:
lazy-mode × verbosity × profile.PONYTAIL_DEFAULT_MODE-Bug als Lehre:ponytail's
hooks/ponytail-config.js:24:Wir sollten den gleichen Path-Resolution-Code in
cmd/sin-code/internal/hooklife/autoactivate/config.goübernehmen (Schritt 3 dieses Issues).safeWriteFlag-Bug als Lehre:ponytail's
safeWriteFlag(CLAUDE.md) lehnt symlink attacks ab:O_NOFOLLOWwhere supported0600Wir übernehmen das 1:1 in
internal/hooklife/autoactivate/safe_write.go(Phase 1, Schritt 2).Quellen:
https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-activate.jshttps://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-mode-tracker.jshttps://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-instructions.js:1-95https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-config.js:1-65https://github.com/DietrichGebert/ponytail/blob/main/CLAUDE.mdDelqhi commentedon Jun 16, 2026
Closed: implementation in PR #211 (feat(hooklife): auto-activation hook for SessionStart + UserPrompt). When PR #211 merges, the feature is live.
merge: resolve main conflicts in PR #211 (issue #176)
feat(skill): add skill-code-lazy bundled skill (issue #178)
feat(skill): add skill-code-lazy bundled skill (issue #178) (#204)
feat(hooklife): auto-activation hook (SessionStart + UserPrompt) (#176)
feat(hooklife): auto-activation hook (SessionStart + UserPrompt) (#176)…
feat(auto_mem): byte-stable MEMORY.md surface + memory auto-* CLI (is…