Skip to content

Commit 9d29a47

Browse files
chore(hooks): add Claude Code session hooks + drift-guard (P3) (#13)
Vendor attune-ai's canonical Claude Code session hooks into attune-help, matching the attune-rag pilot (P1) and attune-author (P2). Vendors the current 8-file closure (includes _sdk_gate.py / the SDK-subprocess fix), so no re-sync is needed. - .claude/hooks/: 8 hooks byte-identical from attune-ai/plugin/hooks (security_guard, format_on_save, compact_warning, spec_orient + helpers _state, _resume_prompt, _transcript_size, _sdk_gate) - .claude/hooks/.canonical-sha256 manifest (8 entries) + Makefile `sync-hooks` target (ATTUNE_AI_ROOT default ../attune-ai) - .claude/settings.json hooks block (SessionStart/Stop/PreToolUse/PostToolUse) - .gitattributes pins LF on the hook tree (stable hashes across OSes) - .gitignore: track .claude/hooks/ + settings.json, ignore the rest - tests: drift-guard (checksum match) + behavior smoke (security_guard block/allow, spec_orient emit/no-op, format_on_save best-effort) Zero-dep budget unaffected: these are dev-time .claude/ files, not runtime dependencies — attune-help's install footprint is unchanged. Refs specs/sibling-claude-hooks (P3). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 869c4c7 commit 9d29a47

15 files changed

Lines changed: 1898 additions & 2 deletions

.claude/hooks/.canonical-sha256

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
cfd43f72b3f64bde6cb779703eb13ea6dd2c55ea5ae3dace654bfa95e17345c9 security_guard.py
2+
37ee358245e8be80b00517c32d586449cb669d6d6e02526cc37c0e6728c452d5 format_on_save.py
3+
f06a2180e64db35f96bdb896fbbfa9bf0ebc5090744817f5b87a7f0fbbb7ec61 compact_warning.py
4+
efba0f96c211161f3bf39223177dd833b2a58a62f6c603a134afc6ed8fbb57c8 spec_orient.py
5+
ddc5bbd50cad7df0ee1cacb61d19bbdac5c4495f247f20505fef6ed844c3fd01 _state.py
6+
63293f305ff32aab46d1da8b9d28c71ce39b658d2a8572c64024614abdf7dffe _resume_prompt.py
7+
baa145fb6fac25ae7d03a5b655b04aba25bfb77793dcdcaf44acc151394f030b _transcript_size.py
8+
48674de791f509c539417b29214d9c87a33b7934b985597af79711ddd90ea17a _sdk_gate.py

.claude/hooks/_resume_prompt.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Resume-prompt builder — single source of truth for the format.
2+
3+
Both the Stop-hook compact warning and the ``/handoff`` slash
4+
command call ``build_resume_prompt`` so the user sees identical
5+
output whether the prompt was triggered automatically or on
6+
demand.
7+
8+
Format: a Markdown blockquote that the user can paste into a
9+
fresh Claude Code session to pick up where they left off.
10+
11+
Copyright 2026 Smart-AI-Memory
12+
Licensed under Apache 2.0
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import sys
18+
from pathlib import Path
19+
20+
# Hooks are invoked as standalone scripts; ensure sibling helpers
21+
# resolve regardless of how this module was loaded (script vs.
22+
# pytest import vs. importlib).
23+
_HOOKS_DIR = str(Path(__file__).resolve().parent)
24+
if _HOOKS_DIR not in sys.path:
25+
sys.path.insert(0, _HOOKS_DIR)
26+
27+
from _state import GitState, SpecInfo # noqa: E402 — sys.path bootstrap above
28+
29+
# Hard cap on the rendered prompt — protects against pathological
30+
# uncommitted-file lists or oversized commit subjects.
31+
_MAX_PROMPT_BYTES = 4096
32+
33+
# Cap on the number of uncommitted files listed before truncation.
34+
_MAX_UNCOMMITTED = 20
35+
36+
37+
def _format_uncommitted(uncommitted: tuple[str, ...]) -> list[str]:
38+
"""Render the uncommitted-file lines with truncation footer."""
39+
if not uncommitted:
40+
return []
41+
visible = list(uncommitted[:_MAX_UNCOMMITTED])
42+
lines = [f"> - {path}" for path in visible]
43+
leftover = len(uncommitted) - len(visible)
44+
if leftover > 0:
45+
lines.append(f"> - +{leftover} more")
46+
return lines
47+
48+
49+
def _format_phase(spec: SpecInfo) -> str:
50+
"""Human-readable ``Phase X (status)`` blurb for the spec."""
51+
phase_label = {
52+
"requirements": "Phase 1 (Requirements)",
53+
"design": "Phase 2 (Design)",
54+
"tasks": "Phase 3 (Tasks)",
55+
}.get(spec.phase, spec.phase)
56+
status = spec.status or "unknown"
57+
return f"{phase_label} — Status: {status}"
58+
59+
60+
def _truncate(text: str) -> str:
61+
"""Truncate to the byte cap with a footer, only if needed."""
62+
encoded = text.encode("utf-8")
63+
if len(encoded) <= _MAX_PROMPT_BYTES:
64+
return text
65+
footer = "\n> …(truncated)\n"
66+
budget = _MAX_PROMPT_BYTES - len(footer.encode("utf-8"))
67+
if budget <= 0:
68+
return text[:_MAX_PROMPT_BYTES]
69+
truncated = encoded[:budget].decode("utf-8", errors="ignore")
70+
return truncated + footer
71+
72+
73+
def _read_current_task(spec: SpecInfo) -> str:
74+
"""Pick a one-line ``current task`` derived from tasks.md.
75+
76+
Strategy: scan ``tasks.md`` for the most-recent
77+
``Status: completed`` row in the implementation table and
78+
quote the row's task description. Caller may override with a
79+
live TodoWrite snapshot via ``todo_summary`` when available.
80+
81+
Returns an empty string when no completed task is found
82+
(e.g. spec is at requirements/design phase).
83+
"""
84+
tasks_file = spec.path / "tasks.md"
85+
if not tasks_file.is_file():
86+
return ""
87+
try:
88+
text = tasks_file.read_text(encoding="utf-8", errors="replace")
89+
except OSError:
90+
return ""
91+
last_completed = ""
92+
for line in text.splitlines():
93+
if "completed" not in line.lower():
94+
continue
95+
if not line.lstrip().startswith("|"):
96+
continue
97+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
98+
if len(cells) < 3:
99+
continue
100+
# Expected layout: | # | Task | Status | Notes |
101+
status_cell = cells[2].lower() if len(cells) > 2 else ""
102+
if "completed" not in status_cell and "complete" not in status_cell:
103+
continue
104+
task_cell = cells[1]
105+
# Strip leading bold/markdown formatting for readability.
106+
task_cell = task_cell.replace("**", "").strip()
107+
if task_cell:
108+
last_completed = task_cell
109+
return last_completed
110+
111+
112+
def build_resume_prompt(
113+
spec_info: SpecInfo | None,
114+
git_state: GitState,
115+
*,
116+
workspace_path: str = "~/attune",
117+
todo_summary: str | None = None,
118+
) -> str:
119+
"""Render the user-facing resume prompt body.
120+
121+
Args:
122+
spec_info: Most-recent in-flight spec, or ``None`` for a
123+
generic fallback that points at the last commit.
124+
git_state: Branch / last-commit / uncommitted snapshot
125+
from ``_state.git_state``.
126+
workspace_path: Display path for the worktree (purely
127+
cosmetic — what the user pastes into a fresh session).
128+
todo_summary: Optional one-line ``current task`` override.
129+
When provided, takes precedence over the
130+
tasks.md-derived fallback.
131+
132+
Returns:
133+
Markdown blockquote ending with a ``Pick up …`` line.
134+
Always under 4 kB.
135+
"""
136+
branch = git_state.branch or "<unknown>"
137+
last_commit = ""
138+
if git_state.last_sha:
139+
subject = git_state.last_subject or "(no subject)"
140+
last_commit = f"`{git_state.last_sha} {subject}`"
141+
142+
lines: list[str] = ["**Resume prompt for a fresh session:**", ""]
143+
blockquote: list[str] = [
144+
f"> Resume work in worktree `{workspace_path}` on branch `{branch}`.",
145+
]
146+
if last_commit:
147+
blockquote.append(f"> Last commit: {last_commit}.")
148+
149+
if spec_info is not None:
150+
blockquote.append(">")
151+
spec_relative = _spec_display_path(spec_info)
152+
blockquote.append(f"> Active spec: `{spec_relative}` — {_format_phase(spec_info)}.")
153+
current_task = todo_summary or _read_current_task(spec_info)
154+
if current_task:
155+
blockquote.append(f"> Current task: {current_task}")
156+
uncommitted_lines = _format_uncommitted(git_state.uncommitted)
157+
if uncommitted_lines:
158+
blockquote.append(">")
159+
blockquote.append("> Uncommitted:")
160+
blockquote.extend(uncommitted_lines)
161+
blockquote.append(">")
162+
blockquote.append(f"> Pick up where the spec left off in `{spec_relative}tasks.md`.")
163+
else:
164+
if todo_summary:
165+
blockquote.append(">")
166+
blockquote.append(f"> Current task: {todo_summary}")
167+
blockquote.append(">")
168+
blockquote.append("> No active spec; pick up from the last commit's diff.")
169+
170+
lines.extend(blockquote)
171+
lines.append("")
172+
lines.append("(Copy the block above into a fresh Claude Code session.)")
173+
return _truncate("\n".join(lines))
174+
175+
176+
def _spec_display_path(spec_info: SpecInfo) -> str:
177+
"""Pretty-relative display path for a spec.
178+
179+
Aims for ``specs/<slug>/`` (workspace) or
180+
``<layer>/specs/<slug>/`` (layer-scoped). Falls back to the
181+
absolute path if the layout doesn't match.
182+
"""
183+
parts = spec_info.path.parts
184+
try:
185+
idx = parts.index("specs")
186+
except ValueError:
187+
return str(spec_info.path)
188+
relative = "/".join(parts[idx:]) + "/"
189+
if spec_info.layer != "workspace":
190+
relative = f"{spec_info.layer}/{relative}"
191+
# Avoid duplicating the layer if it was already in parts.
192+
if Path(relative).parts.count(spec_info.layer) > 1:
193+
relative = "/".join(parts[idx:]) + "/"
194+
return relative

.claude/hooks/_sdk_gate.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Detect SDK-spawned subprocess sessions so hooks can self-gate.
2+
3+
sdk-subprocess-isolation spec (R1/R2, decision D1: gate everything):
4+
an SDK-spawned ``claude`` subprocess is not an interactive session —
5+
no attune hook applies there, and SessionStart hook stdout poisons
6+
the SDK's stream-json channel (the failure that broke every SDK
7+
workflow for subscription users). Every hook script calls
8+
:func:`exit_if_sdk_subprocess` as its first ``__main__`` statement.
9+
10+
Two detection signals (spec D3):
11+
12+
- ``ATTUNE_SDK_SUBPROCESS=1`` — attune's explicit marker, set by
13+
``agent_sdk_adapter.sdk_isolation_kwargs()`` (Phase 2).
14+
- ``CLAUDE_CODE_ENTRYPOINT`` starting with ``sdk-`` — stamped by the
15+
Agent SDK itself into every subprocess env, so the gate also covers
16+
third-party SDK scripts that never touch attune's adapter.
17+
Interactive sessions carry other values (``claude-desktop``, etc.).
18+
19+
Twin copy: ``src/attune/hooks/scripts/_sdk_gate.py`` (repo-level
20+
hooks). Keep both in sync — each is imported from its own script dir.
21+
22+
Copyright 2026 Smart-AI-Memory
23+
Licensed under Apache 2.0
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import os
29+
import sys
30+
31+
32+
def is_sdk_subprocess() -> bool:
33+
"""True when running inside an SDK-spawned ``claude`` subprocess."""
34+
if os.environ.get("ATTUNE_SDK_SUBPROCESS") == "1":
35+
return True
36+
return os.environ.get("CLAUDE_CODE_ENTRYPOINT", "").startswith("sdk-")
37+
38+
39+
def exit_if_sdk_subprocess() -> None:
40+
"""Exit 0 with no output when inside an SDK subprocess session."""
41+
if is_sdk_subprocess():
42+
sys.exit(0)

0 commit comments

Comments
 (0)