Repository navigation
feat(hooks): Claude Code lifecycle reminders to pull and push Ditto memory [DITTO-2706] - #29
Conversation
…emory [DITTO-2706] Ship three local, hash-pinned Claude Code hooks: SessionStart context that points the agent at the ditto-memory skill, PreCompact summary instructions plus a post-compaction reminder, and a once-per-session Stop check after real work. The script makes no network calls, fails open within a 5 s timeout, and is disabled with DITTO_HOOKS=0 (or DITTO_HOOKS_STOP=0 for the Stop check only). The validator allows hook files only when pinned in scripts/hook-allowlist.json, wired through the Claude manifest (never the hooks/hooks.json default that Cursor, Codex, Copilot and Grok discover by themselves), with command handlers, bounded timeouts and no network tools. docs/hooks.md records the per-host research and why other events and hosts are not shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@ditto review |
omarzanji
left a comment
There was a problem hiding this comment.
Spot-check of the draft. The hooks are local-only, text-only, opt-out and exit 0 on failure, which is the right posture. Two points.
- The Stop hook returns
decision: block, which forces another model turn. It fires once per session after a file edit or 10 tool calls with no Ditto save. In interactive use that is a nudge; in headlessclaude -p, scheduled or CI sessions nobody can answer "has capture been authorized?", and it costs a turn and tokens per session for every plugin user. Consider default-off for the Stop check (DITTO_HOOKS_STOPopt-in), or skip it when the session is non-interactive. - SessionStart nags users who have not connected Ditto. It prints about 100 words into context every start/resume/clear and tells the agent to "mention it once" if the server is not connected. A user who installed the plugin but has not authorized the MCP sees that every session. Is there a cheap signal (the plugin's MCP config state, or a marker written by the first successful
ditto-memorycall) to stay quiet until the server has connected once?
Also worth a test: stop depends on the transcript line format ("type":"tool_use","id":"...","name":"). If Claude Code reorders those keys the hook silently never fires; a fixture pinned to the current format plus a note in docs/hooks.md would make that visible.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
… exit 2 [DITTO-2706] Address Omar's spot-check on #29: - Every reminder is silent until a Ditto memory tool has succeeded once. A PostToolUse hook matched only to Ditto's memory tools writes an empty `connected` marker; the validator rejects any other PostToolUse matcher. - The Stop check skips unattended sessions (CLAUDE_CODE_SESSION_ATTENDED=0 in claude -p and the Agent SDK, with CLAUDE_CODE_ENTRYPOINT=sdk-* as the fallback); DITTO_HOOKS_STOP=1 turns it on for automations. - The Stop check matches tool_use blocks in either key order and is pinned to a real Claude Code 2.1.308 transcript line (tests/fixtures). - Stop continues through hookSpecificOutput.additionalContext, which Claude Code does not label "Stop hook error". - Fix a fail-open hole found by the new read-only data dir test: under dash a failed redirect on `:` exits the shell with 2, Claude Code's blocking code. Markers use touch, and the body runs in a subshell before an unconditional exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @omarzanji. All three points are addressed in 718b7e8. 1. Stop in headless runs. The Stop check now skips unattended sessions. I measured the signal on Claude Code 2.1.308 by dumping the hook environment:
The check runs only when
2. Nagging users who never connected Ditto. Every reminder is now silent until a Ditto memory tool has succeeded once in that installation:
3. Transcript format. The check now counts a tool call on either
A bug the new tests found, now fixed. A read-only data-directory test caught a real fail-open hole that was also present at
Evidence
|
|
@ditto review |
✅ Ditto reviewed
|
There was a problem hiding this comment.
Ditto Review
No blocking issues found · risk: medium · 670edd8
File coverage: 3 fully reviewed, 0 reviewed briefly, 0 not reviewed.
Add hash-pinned Claude Code hooks for Ditto memory reminders — Adds Claude Code lifecycle hooks that remind the agent to recall Ditto memory at session start, preserve memory references through compaction, and consider saving outcomes at session end. The reminders stay silent until a Ditto memory tool succeeds; the stop check is gated by attendance, recent activity, and whether memory was already saved. Review the shell hook’s transcript matching and marker-file logic closely, along with the package validator’s hash-pinning and hook restrictions.
Walkthrough (19 files)
| File | Change |
|---|---|
.github/CODEOWNERS |
Assigns owners to hook code, hook configuration, validation, and GitHub workflow changes. |
CONTRIBUTING.md |
Explains that new plugins start without hooks and that hook files must be hash-pinned. |
README.md |
Documents the Claude Code reminders, opt-out settings, and hook behavior. |
RELEASING.md |
Updates release guidance to cover local reminder hooks and their pinned hashes. |
adapters/ditto-memory.md |
Clarifies that hooks provide reminders but never call Ditto themselves. |
docs/hooks.md |
Describes hook events, safety properties, host support, and verification details. |
docs/shared-skills.md |
Updates the package description to identify the pinned Claude Code hooks as runtime files. |
plugins/ditto/.claude-plugin/plugin.json |
Wires the Ditto Claude Code manifest to its hook configuration. |
plugins/ditto/README.md |
Documents the hooks’ reminders, stop-check conditions, and opt-out controls for plugin users. |
plugins/ditto/hooks/claude-hooks.json |
Configures hooks for successful Ditto memory tools, session start, compaction, and stop events. |
plugins/ditto/hooks/ditto-hook.sh |
Implements local reminders and marker tracking, including a gated end-of-session save check. |
plugins/ditto/shared-skills.lock.json |
Refreshes skill and adapter content hashes to match the updated guidance. |
plugins/ditto/skills/ditto-memory/SKILL.md |
Adds Claude Code hook context while retaining the skill’s memory authorization guidance. |
plugins/ditto/skills/tdd/SKILL.md |
Updates the skill’s description of bundled runtime components. |
scripts/hook-allowlist.json |
Pins the approved hook configuration and script by SHA-256. |
scripts/validate_package.py |
Allows only approved, hash-pinned hooks and validates their configuration, timeouts, and restrictions. |
tests/fixtures/claude-code-2.1.308-write.jsonl |
Adds a Claude Code transcript fixture for testing stop-check tool-call detection. |
tests/test_hooks.py |
Tests hook validation, pinning, opt-outs, reminders, and stop-check behavior. |
tests/test_package.py |
Adapts package tests for the approved hook exception and unhooked new-plugin defaults. |
Review details
- Models: review
z-ai/glm-5.3-flash· verifyopenai/gpt-6-luna - Findings: 0 candidates → 0 verified → 0 posted
- Context index: 72 files · 24 changed symbols · 92 references
- Tokens: 187771 in / 12140 out · $0.00
- Incremental since
798b18d - Run:
3a14152b-bd46-49aa-ac84-5058cbbaa595· engine v2.0.0 · details - Ditto approves or requests changes in a separate review (blocking: 🟠 High and above). Inference is billed through your Ditto Router endpoint.
Open in Ditto · reply @ditto review to re-run · @ditto fix to push fixes
omarzanji
left a comment
There was a problem hiding this comment.
Approving. All three points from my spot-check at a6ce55b are addressed at 718b7e8. What I found beyond them is Low.
Spot-check follow-up
- Stop hook cost in headless runs: fixed.
unattended()skips the check whenCLAUDE_CODE_SESSION_ATTENDED=0or the entrypoint issdk-*, unlessDITTO_HOOKS_STOP=1is set. Output also moved fromdecision: blocktoadditionalContext.test_stop_skips_unattended_sessions_unless_askedcovers this. - Nudging users without Ditto connected: fixed. Every reminder is gated on the
connectedmarker. Only aPostToolUsematched to Ditto memory tools writes that marker, andPostToolUseruns only after a tool succeeds (PostToolUseFailureis a separate event).test_silent_until_a_ditto_memory_tool_has_workedcovers this. - Transcript key order: fixed.
calls()matches either anchor and is pinned to the real 2.1.308 fixture (test_stop_reads_a_real_claude_code_transcript), andtest_stop_tolerates_reordered_tool_use_keyscovers the reordered case.
I read Ditto Review's seven threads on this head and haven't repeated them. The slash-unescape order in field() and the null/non-string checks in the validator are worth taking.
Low (non-blocking)
plugins/ditto/hooks/ditto-hook.sh:89: a user who keeps declining capture is offered it again in every attended session where they edit files, and each offer costs an extra turn over the full context. The skill's "ask before enabling ongoing capture" reads like asking once. Suggestions: cap the offer, for example skip it when anystop-check-*marker is newer than N days, or make the text say "offer only if you have not already been told no in this project".- The
connectedmarker never expires. Someone who removes or de-authorizes the Ditto server keeps the SessionStart text and the Stop turn until they setDITTO_HOOKS=0. A 30-day-mtimerefresh, like the one the stop markers already get, would let it lapse:PostToolUsetouches it again on every Ditto call. - Edits made inside subagents land in their own sidechain transcripts, so a session that delegates its file writes only counts the
Taskcalls toward the ten-call threshold. That's fine as a heuristic. One line indocs/hooks.mdwould explain it.
Rollout (not a finding)
Manifests stay at 0.4.1, so installed users won't get the hooks until a version bump, as the description says. Marketplace-install behaviour is listed as unverified. It's worth one check after the bump, because ${CLAUDE_PLUGIN_DATA} being set is what keeps every hook silent otherwise.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
- validator: reject non-string `hooks` values and hook commands with ValueError instead of crashing, and reject allowlist keys that are not plugins/<name>/hooks/<file>. - ditto-hook.sh: unescape JSON paths in one left-to-right pass so a literal backslash before a separator survives; skip the connected marker for MCP error results; write the Stop marker after the reminder (checking the data dir is writable first, so a read-only dir cannot repeat it). - CODEOWNERS: also cover plugins/*/.claude-plugin/plugin.json, which decides which hooks run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9bc8e2c
✅ Ditto reviewed
|
|
@ditto review |
omarzanji
left a comment
There was a problem hiding this comment.
Approving again at this head. The delta since my approval of 718b7e8 is the Ditto Review round only, and I checked each part:
- An MCP error result no longer writes
connected. The test covers bothisErrorandis_error, and confirms a non-error result still connects. - The Stop marker is now written after the reminder, with the writability check before it. An interrupted run can no longer suppress a reminder that was never shown, and an unwritable data dir still fails quietly instead of repeating the reminder.
validate_package.pynow rejects non-stringhooksandcommandvalues, and allowlist entries outsideplugins/<name>/hooks/<file>(including..paths). There are tests for each.- CODEOWNERS now also covers
plugin.json, which is the file that decides whether hooks are wired at all. That's a good catch.
Low, non-blocking:
- The
isErrorgrep reads the whole hook input, includingtool_input. A memory whose text happens to contain"isError": truewould not mark Ditto as connected for that call. That fails closed, so it's harmless. - The generic
\\(.\)unescape turns a JSON\nor\tin a transcript path intonort. It's fine for real paths, but worth a comment.
My earlier Lows still stand as follow-ups: the capture offer repeats every session, the connected marker never expires, subagent edits don't count toward the threshold, and the version bump.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
…se [DITTO-2706] From Omar's Low notes on #29: - The Stop check runs at most once a day per install, and the text tells the agent not to re-offer capture once the user has declined. - The `connected` marker lapses after 30 days without a Ditto memory call; each call refreshes it, so a de-authorized install goes quiet again. - docs/hooks.md notes that subagent edits live in sidechain transcripts and only their Task calls count toward the ten-call threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95aaf3f
|
Thanks @omarzanji. I took all three Lows in 95aaf3f. That push dismisses your approval on 9bc8e2c, sorry for the extra round.
The 9bc8e2c round also took Ditto's seven threads, including the slash-unescape and null/non-string checks you flagged. Tests: 42 pass under dash on Boat. Deleting the daily-cap line or the expiry line on a Boat copy fails the matching test. Rollout note acknowledged. After the version bump, which is Peyton's call, the first check will be a real marketplace install, confirming |
|
@ditto review |
✅ Ditto reviewed
|
omarzanji
left a comment
There was a problem hiding this comment.
Approving again at this head. This commit closes three of my earlier Low notes:
- Repeated capture offers: capped by the 24-hour
stop-check-*mtime check, and the text now tells the agent not to re-offer after a no. connectedmarker that never expired: each Ditto call now refreshes it, and it is removed after 30 days without one.- Subagent edits: now documented as only counting through
Taskcalls toward the ten-call threshold.
Still open as a follow-up: the plugin version bump, so installed users actually receive the hooks.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
find -mtime rounds ages down to whole days, so +30 only matched from day 31. Use +29 and pin the boundary at 30.01 and 29.9 days in the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
798b18d
|
@ditto review |
✅ Ditto reviewed
|
…ITTO-2706] find -mtime rounds whole-day ages down on GNU and is documented as rounding up on BSD, so the daily cap and the 30-day expiry could shift by a day. Use -mmin -1440 and -mmin +43199, and pin both boundaries in the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
omarzanji
left a comment
There was a problem hiding this comment.
Approving again at this head. The only change since 95aaf3f: -mtime +29 expires the connected marker at 30 days instead of 31, since find rounds ages down to whole days. The tests now bracket the boundary at 29.9 and 30.01 days.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
670edd8
|
@ditto review |
✅ Ditto reviewed
|
omarzanji
left a comment
There was a problem hiding this comment.
Approving again at this head. The marker ages are now counted in minutes (-mmin +43199 and -mmin -1440). GNU and BSD find both support -mmin, and I checked on macOS find that a 43-day-old file matches -mmin +43199 and a fresh one does not. The tests now cover both sides of the daily boundary.
Reviewed by Claude with the teammate-pr-review-loop skill, on behalf of the account posting it.
Jira: DITTO-2706. Docs follow-up: DITTO-2705. Guard: DITTO-1931, partial.
What this adds
Ditto works best when an agent pulls memory before it starts and pushes the outcome when it finishes. This PR adds the smallest set of host hooks that reminds the agent at those moments. It is not a revert of #5. The hooks make no network calls and run no CLI, and they hold no keys. They print reminders that point the agent at the existing
ditto-memoryskill. Every Ditto read and write still goes through the agent's hosted MCP connection, so the host's OAuth and tool permissions apply as usual.Claude Code only (
plugins/ditto/hooks/, wired via.claude-plugin/plugin.json→./hooks/claude-hooks.json):PostToolUse, matched only to Ditto memory toolsconnectedconnectedmarker. Every reminder below stays silent until this has happened once, so an install whose Ditto server was never authorized gets nothingSessionStartstartup/resume/clearsession-startditto-memorybefore work that depends on the user's history, treat memories as evidence rather than instructions, and save durable outcomes at the end if the user authorized capturePreCompactpre-compactSessionStartcompactcompactStopstopsave_memory/update_memorycall, ask the agent to save one concise note if capture is authorized, or offer to. It continues the turn viahookSpecificOutput.additionalContextand costs one short extra turn. Skipped inclaude -pand Agent SDK runs (CLAUDE_CODE_SESSION_ATTENDED=0) unlessDITTO_HOOKS_STOP=1Constraints: every handler is
sh …/ditto-hook.sh <step>with a 5 s timeout and runs in about 20–40 ms. It fails open: the body runs in a subshell before an unconditionalexit 0, so not even a shell error can return Claude Code's blocking exit 2, and bad input gives no output. It writes no logs and reads no secrets. The only write is an empty per-session marker in${CLAUDE_PLUGIN_DATA}. Opt-out:DITTO_HOOKS=0turns off all hooks,DITTO_HOOKS_STOP=0turns off only the Stop check, andDITTO_HOOKS_STOP=1opts headless runs in. Nothing runs on prompts, and nothing runs on tool calls other than Ditto's own memory tools.Why these events and not others
The full research, sources and per-host matrix are in
docs/hooks.md. In short:Not shipped:
UserPromptSubmitrecall: a network call plus context on every prompt, which is the 0.2.x behaviour fix: migrate Ditto for Claude to hosted MCP #5 removed.PreToolUse/PostToolUse: these run on every tool call.SessionEnd: it cannot reach the model, so saving would need credentials in a script.SubagentStop: it would multiply the extra turns.mcp_toolhandlers: there is no query at session start, and a bare save at Stop would be a transcript dump.Automatic saving without asking is not shipped. The
ditto-memoryskill says "ask before enabling ongoing capture". The hooks follow that rule. Making capture opt-out by default is a product call for ditto-skills.Other hosts get no hooks in this PR.
sessionStartis fire-and-forget.sessionStarttakes only top-leveladditionalContext.SessionStartcontext.injectSteps, roothooks.json.SessionStartis observe-only.On those hosts the skills remain the path. Codex and Cursor are candidate follow-ups.
Why the file is not
hooks/hooks.jsonClaude Code, Cursor, Codex, Copilot CLI, VS Code and Grok all load
hooks/hooks.jsonfrom a plugin folder with no manifest field, verified against docs plus thecursor-agent,codex,copilotandgrokbinaries. In one shared folder, that file would run Claude-format hooks on every one of those hosts. The hooks therefore live athooks/claude-hooks.json, and only the Claude manifest names them. Every other host reads its own manifest first, finds nohooksfield and no default file, and loads nothing.grok plugin validateconfirms that Grok sees no hook component.Tamper guard (DITTO-1931, part 1)
scripts/validate_package.pynow rejects all of the following:scripts/hook-allowlist.json, so changed bytes need a visible hash changehooks/hooks.jsonat the default path, even when pinnedhooks.jsonthat Copilot and Antigravity readhooksin any manifest other than Claude's, andhooksin marketplace entrieshttp,mcp_tool,asyncand extra fields included)The ChatGPT upload carries no hooks.
.github/CODEOWNERSnow requests Peyton and Omar onplugins/*/hooks/, the allowlist, the validator and.github/. Themain protectionruleset hasrequire_code_owner_review: false, so the file is advisory until an owner turns that on. I did not change the ruleset. The daily served-copy drift check and signed commits from DITTO-1931 remain open.Other changes
RELEASING.md,CONTRIBUTING.md,docs/shared-skills.md: the old statement "no lifecycle hooks" is replaced with what now ships.adapters/ditto-memory.md: same update.skills/ditto-memory/SKILL.mdwas regenerated from the locked sourcef1b8c1b, and only the adapter changed.tdd/SKILL.md. I kept Prepare Ditto directory plugin disclosures and identifier #27's text and its lock hash, and changed "bundles no executable code or test runner" to "bundles no test runner", which is accurate now. That sentence still does not exist in ditto-skills, a drift that predates this PR.Review round 1 (Omar's spot-check), fixed in 718b7e8
ENTRYPOINT=cliwithATTENDED=1;claude -pissdk-cliwith0.connectedgate above.:exited the shell with 2 (blocking). This was present ata6ce55band is now fixed.decision: block, which the terminal labels "Stop hook error", toadditionalContext.Review round 2 (Ditto Review on 718b7e8, approved with 7 non-blocking findings), fixed in 9bc8e2c
hooksvalues and hook commands now raiseValueErrorinstead ofTypeError. Allowlist keys must have the formplugins/<name>/hooks/<file>./survives.connectedmarker: MCP error results (isError) no longer create it..claude-plugin/plugin.json.Review round 3 (Omar's Lows on 718b7e8), fixed in 95aaf3f
connectedmarker: it lapses after 30 days without a Ditto call.Verification
python3 -m unittest discover -s tests: 42 tests OK at95aaf3fon Boat builderbx_ybpvfzcj(Ubuntu,/bin/sh= dash).test_hooks.pyruns the real script undershwith hook stdin. It covers:stop_hook_activeloop guardMutation controls added in round 1: dropping the reordered-key match fails
test_stop_tolerates_reordered_tool_use_keys; removing the unattended skip failstest_stop_skips_unattended_sessions_unless_asked; the original:redirect failstest_read_only_data_dir_fails_open_quietly.Mutation controls on the Boat copy: loosening the tool-call pattern fails
test_stop_stays_quiet_without_work_or_after_a_save, and removing the marker check failstest_stop_asks_once_per_session_after_real_work. Both pass again after restore.python3 scripts/validate_package.pyreports 44 files and 2 pinned hook files.shared_skills.py verifypasses.claude plugin validate ./plugins/dittopasses; a renamed event in a copy is reported asunknown hook event, so the validator does read this file.grok plugin validate ./plugins/dittopasses with no hook component.Live, 2026-11-13, Claude Code 2.1.308, with a local stdio test double named
dittoservingsearch_memories:PostToolUsefired onmcp__ditto__search_memoriesand wroteconnected, and Stop was silent (unattended).DITTO_HOOKS_STOP=1: the context was injected, Stop asked once, and the second Stop was silent.Live, 2026-11-10, before round 1,
claude -p --plugin-dir, haiku, no Ditto account connected:SessionStart:startupinjected the context in 22 ms.Writegot one Stop check. The agent replied with one line ("nothing durable… nothing to save"), and the second Stop was silent.Bashcall got no check./compacton the resumed session firedSessionStart:compactafter the boundary, and the PreCompact text reached the compaction.An earlier live run caught two bugs, both fixed and covered by tests:
"name":"Edit"pattern.Not verified:
shis missing and the hooks fail open.--plugin-dir.🤖 Generated with Claude Code