claude-dev-env 8.88.6 → 8.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,11 +10,14 @@
10
10
  oracle (raw extract): facts 1.00 ... null (empty reply): facts 0.00 ...
11
11
 
12
12
  Each case is a fixture transcript with planted facts: paths, numbers, a
13
- link, and a decision the user later reversed. The runner writes the
14
- extract with ``compaction_user_messages.py``, then sends the agent hook's
15
- own prompt from ``hooks.json`` to ``claude -p`` with the Read tool, the way
16
- Claude Code runs the hook. The grader checks that each fact survives and
17
- that the reversed line says ``overruled``.
13
+ link, and a decision the user later reversed.
14
+
15
+ Each run starts a headless ``claude -p`` session whose settings hold the
16
+ condensing hook from ``hooks.json`` and the Read allow rules from the
17
+ package ``settings.json``. The session runs the extractor on the fixture, so
18
+ Claude Code fires the hook as it would after a compaction. The runner reads
19
+ the hook's returned reason from the session's debug log. The grader checks
20
+ that each fact survives and that the reversed line says ``overruled``.
18
21
 
19
22
  The oracle grades the raw extract and the null grades an empty reply, so a
20
23
  broken grader shows before any model score is trusted. A failed call goes
@@ -26,11 +29,11 @@ from __future__ import annotations
26
29
  import argparse
27
30
  import json
28
31
  import re
32
+ import shlex
29
33
  import shutil
30
34
  import subprocess
31
35
  import sys
32
36
  import tempfile
33
- import uuid
34
37
  from dataclasses import dataclass, field
35
38
  from pathlib import Path
36
39
 
@@ -41,22 +44,29 @@ _session_directory = str(Path(__file__).resolve().parent)
41
44
  if _session_directory not in sys.path:
42
45
  sys.path.insert(0, _session_directory)
43
46
 
44
- from compaction_user_messages import output_path_for, rendered_extract, typed_user_messages
47
+ from compaction_user_messages import rendered_extract, typed_user_messages
45
48
  from hooks_constants.compaction_user_messages_constants import (
46
- EVAL_ARGUMENTS_PLACEHOLDER,
49
+ EVAL_ALLOWED_TOOLS,
47
50
  EVAL_CASES_FILE_NAME,
48
51
  EVAL_CLAUDE_EXECUTABLE,
52
+ EVAL_DEBUG_FILE_NAME,
53
+ EVAL_DECISION_SEPARATOR,
49
54
  EVAL_DEFAULT_MODEL,
55
+ EVAL_EMPTY_MCP_CONFIG,
50
56
  EVAL_FIXTURE_DIRECTORY,
51
57
  EVAL_HOOK_TYPE,
52
58
  EVAL_HOOKS_JSON_PATH,
53
59
  EVAL_JSON_OBJECT_START,
54
60
  EVAL_MESSAGE_LINE_PATTERN_TEMPLATE,
55
61
  EVAL_OVERRULED_MARKER,
62
+ EVAL_PACKAGE_SETTINGS_PATH,
56
63
  EVAL_RESULTS_FILE_NAME,
57
64
  EVAL_RESULTS_JSON_INDENT,
65
+ EVAL_RUN_PROMPT_TEMPLATE,
66
+ EVAL_RUN_SETTINGS_FILE_NAME,
58
67
  EVAL_SERVED_MODEL_SEPARATOR,
59
68
  EVAL_SESSION_DIRECTORY,
69
+ EVAL_STRUCTURED_OUTPUT_MARKER,
60
70
  EVAL_TIMEOUT_SECONDS,
61
71
  EXTRACTOR_SCRIPT_NAME,
62
72
  MESSAGES_HEADING,
@@ -145,11 +155,11 @@ def score_condensed(
145
155
  )
146
156
 
147
157
 
148
- def condensing_hook_prompt(hooks_json_text: str) -> str:
149
- """Return the prompt of the PostToolUse agent hook that condenses the messages."""
158
+ def condensing_hook_group(hooks_json_text: str) -> dict[str, object]:
159
+ """Return the PostToolUse group of ``hooks.json`` cut down to the condensing agent hook."""
150
160
  all_registrations = json.loads(hooks_json_text)["hooks"]["PostToolUse"]
151
161
  return next(
152
- str(each_hook["prompt"])
162
+ {"matcher": each_group["matcher"], "hooks": [each_hook]}
153
163
  for each_group in all_registrations
154
164
  for each_hook in each_group["hooks"]
155
165
  if each_hook.get("type") == EVAL_HOOK_TYPE
@@ -157,6 +167,17 @@ def condensing_hook_prompt(hooks_json_text: str) -> str:
157
167
  )
158
168
 
159
169
 
170
+ def run_settings(
171
+ all_group_fields: dict[str, object], all_allow_rules: list[str], model_name: str
172
+ ) -> dict[str, object]:
173
+ """Return headless session settings that register the hook on the given model."""
174
+ all_hooks = [{**each_hook, "model": model_name} for each_hook in all_group_fields["hooks"]]
175
+ return {
176
+ "permissions": {"allow": all_allow_rules},
177
+ "hooks": {"PostToolUse": [{**all_group_fields, "hooks": all_hooks}]},
178
+ }
179
+
180
+
160
181
  def hook_reason(model_reply: str) -> str | None:
161
182
  """Return the ``reason`` of the last JSON decision in the reply, or None when there is none."""
162
183
  decoder = json.JSONDecoder()
@@ -173,53 +194,74 @@ def hook_reason(model_reply: str) -> str | None:
173
194
  return found_reason
174
195
 
175
196
 
176
- def _write_extract(transcript_path: Path) -> tuple[Path, str]:
177
- all_lines = transcript_path.read_text(encoding=OUTPUT_ENCODING).splitlines()
178
- extract_text = rendered_extract(typed_user_messages(all_lines))
179
- extract_path = output_path_for(transcript_path)
180
- extract_path.write_text(extract_text, encoding=OUTPUT_ENCODING)
181
- return extract_path, extract_text
197
+ def debug_log_reason(debug_text: str) -> str | None:
198
+ """Return the reason of the last hook decision a debug log records, or None.
182
199
 
200
+ ::
183
201
 
184
- def _hook_input(transcript_path: Path, extract_path: Path) -> str:
185
- command = f"python3 {EVAL_SESSION_DIRECTORY / EXTRACTOR_SCRIPT_NAME} {transcript_path}"
186
- return json.dumps(
187
- {
188
- "hook_event_name": "PostToolUse",
189
- "tool_name": "Bash",
190
- "tool_input": {"command": command},
191
- "tool_response": {"stdout": f"{extract_path}\n", "stderr": "", "interrupted": False},
192
- }
193
- )
202
+ "... Hooks: Got structured output: {\"ok\":false,\"reason\":\"#1 deploy\"}"
203
+ -> "#1 deploy"
194
204
 
205
+ Args:
206
+ debug_text: The ``--debug-file`` log of one headless session.
195
207
 
196
- def _ask_model(prompt_text: str, model_name: str) -> dict[str, object]:
197
- command = [
208
+ Returns:
209
+ The text Claude Code handed the main agent, or None when no hook answered.
210
+ """
211
+ all_decisions = [
212
+ each_line.split(EVAL_STRUCTURED_OUTPUT_MARKER, 1)[1]
213
+ for each_line in debug_text.splitlines()
214
+ if EVAL_STRUCTURED_OUTPUT_MARKER in each_line
215
+ ]
216
+ return hook_reason(EVAL_DECISION_SEPARATOR.join(all_decisions))
217
+
218
+
219
+ def _session_command(
220
+ transcript_path: Path, settings_path: Path, model_name: str, debug_path: Path
221
+ ) -> list[str]:
222
+ extractor_command = shlex.join(
223
+ ["python3", str(EVAL_SESSION_DIRECTORY / EXTRACTOR_SCRIPT_NAME), str(transcript_path)]
224
+ )
225
+ return [
198
226
  shutil.which(EVAL_CLAUDE_EXECUTABLE) or EVAL_CLAUDE_EXECUTABLE,
199
227
  "-p",
200
228
  "--model",
201
229
  model_name,
202
230
  "--setting-sources",
203
231
  "",
204
- "--tools",
205
- "Read",
206
- "--allowedTools=Read",
232
+ "--settings",
233
+ str(settings_path),
234
+ "--strict-mcp-config",
235
+ "--mcp-config",
236
+ EVAL_EMPTY_MCP_CONFIG,
237
+ "--allowedTools",
238
+ EVAL_ALLOWED_TOOLS,
207
239
  "--no-session-persistence",
208
- "--session-id",
209
- str(uuid.uuid4()),
240
+ "--debug-file",
241
+ str(debug_path),
210
242
  "--output-format",
211
243
  "json",
244
+ EVAL_RUN_PROMPT_TEMPLATE.format(command=extractor_command),
212
245
  ]
246
+
247
+
248
+ def _run_hook_live(
249
+ transcript_path: Path, settings_path: Path, model_name: str
250
+ ) -> tuple[str | None, dict[str, object]]:
251
+ run_directory = Path(tempfile.mkdtemp(dir=settings_path.parent))
252
+ debug_path = run_directory / EVAL_DEBUG_FILE_NAME
213
253
  completed = subprocess.run(
214
- command,
215
- input=prompt_text,
254
+ _session_command(transcript_path, settings_path, model_name, debug_path),
255
+ stdin=subprocess.DEVNULL,
216
256
  capture_output=True,
217
257
  text=True,
218
258
  timeout=EVAL_TIMEOUT_SECONDS,
219
- cwd=tempfile.gettempdir(),
259
+ cwd=run_directory,
220
260
  check=False,
221
261
  )
222
- return json.loads(completed.stdout)
262
+ session_output = json.loads(completed.stdout)
263
+ debug_text = debug_path.read_text(encoding=OUTPUT_ENCODING, errors="replace")
264
+ return debug_log_reason(debug_text), session_output
223
265
 
224
266
 
225
267
  def _run_line(run_label: str, case_score: CaseScore, all_served_models: list[str]) -> str:
@@ -233,20 +275,20 @@ def _run_line(run_label: str, case_score: CaseScore, all_served_models: list[str
233
275
 
234
276
  def _run_once(
235
277
  run_label: str,
236
- prompt_text: str,
278
+ transcript_path: Path,
279
+ settings_path: Path,
237
280
  model_name: str,
238
281
  all_case_fields: dict[str, object],
239
282
  extract_text: str,
240
283
  tally: EvalTally,
241
284
  ) -> None:
242
285
  try:
243
- model_output = _ask_model(prompt_text, model_name)
286
+ condensed_text, model_output = _run_hook_live(transcript_path, settings_path, model_name)
244
287
  except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as call_error:
245
288
  tally.all_errors.append(f"{run_label}: {type(call_error).__name__}")
246
289
  return
247
- condensed_text = hook_reason(str(model_output.get("result", "")))
248
- if model_output.get("is_error") or condensed_text is None:
249
- tally.all_errors.append(f"{run_label}: no reason in reply")
290
+ if condensed_text is None:
291
+ tally.all_errors.append(f"{run_label}: no hook reason in the debug log")
250
292
  return
251
293
  case_score = score_condensed(condensed_text, all_case_fields, extract_text)
252
294
  all_served_models = sorted(dict(model_output.get("modelUsage") or {}))
@@ -264,21 +306,20 @@ def _run_once(
264
306
 
265
307
  def _run_case(
266
308
  all_case_fields: dict[str, object],
267
- hook_prompt: str,
309
+ settings_path: Path,
268
310
  parsed_arguments: argparse.Namespace,
269
311
  tally: EvalTally,
270
312
  ) -> None:
271
313
  transcript_path = EVAL_FIXTURE_DIRECTORY / str(all_case_fields["transcript"])
272
- extract_path, extract_text = _write_extract(transcript_path)
314
+ all_lines = transcript_path.read_text(encoding=OUTPUT_ENCODING).splitlines()
315
+ extract_text = rendered_extract(typed_user_messages(all_lines))
273
316
  tally.all_oracle_scores.append(score_condensed(extract_text, all_case_fields, extract_text))
274
317
  tally.all_null_scores.append(score_condensed("", all_case_fields, extract_text))
275
- prompt_text = hook_prompt.replace(
276
- EVAL_ARGUMENTS_PLACEHOLDER, _hook_input(transcript_path, extract_path)
277
- )
278
318
  for each_rep in range(1, parsed_arguments.reps + 1):
279
319
  _run_once(
280
320
  f"{transcript_path.name} rep {each_rep}",
281
- prompt_text,
321
+ transcript_path,
322
+ settings_path,
282
323
  parsed_arguments.model,
283
324
  all_case_fields,
284
325
  extract_text,
@@ -322,7 +363,7 @@ def _write_report(tally: EvalTally, output_directory: Path | None) -> None:
322
363
 
323
364
 
324
365
  def main() -> int:
325
- """Run every case through the hook prompt, grade it, and print the scores."""
366
+ """Run every case through the live hook, grade it, and print the scores."""
326
367
  argument_parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
327
368
  argument_parser.add_argument("--reps", type=int, default=1)
328
369
  argument_parser.add_argument("--model", default=EVAL_DEFAULT_MODEL)
@@ -331,10 +372,18 @@ def main() -> int:
331
372
  all_cases = json.loads(
332
373
  (EVAL_FIXTURE_DIRECTORY / EVAL_CASES_FILE_NAME).read_text(encoding=OUTPUT_ENCODING)
333
374
  )
334
- hook_prompt = condensing_hook_prompt(EVAL_HOOKS_JSON_PATH.read_text(encoding=OUTPUT_ENCODING))
375
+ hook_group = condensing_hook_group(EVAL_HOOKS_JSON_PATH.read_text(encoding=OUTPUT_ENCODING))
376
+ all_allow_rules = json.loads(EVAL_PACKAGE_SETTINGS_PATH.read_text(encoding=OUTPUT_ENCODING))[
377
+ "permissions"
378
+ ]["allow"]
379
+ settings_path = Path(tempfile.mkdtemp()) / EVAL_RUN_SETTINGS_FILE_NAME
380
+ settings_path.write_text(
381
+ json.dumps(run_settings(hook_group, all_allow_rules, parsed_arguments.model)),
382
+ encoding=OUTPUT_ENCODING,
383
+ )
335
384
  tally = EvalTally()
336
385
  for each_case in all_cases:
337
- _run_case(each_case, hook_prompt, parsed_arguments, tally)
386
+ _run_case(each_case, settings_path, parsed_arguments, tally)
338
387
  _write_report(tally, parsed_arguments.output_dir)
339
388
  return 0 if tally.all_model_scores and not tally.all_errors else 1
340
389
 
@@ -4,22 +4,23 @@
4
4
  ::
5
5
 
6
6
  python3 compaction_user_messages.py ~/.claude/projects/p/abc.jsonl
7
- -> /tmp/compaction-user-messages-abc.md
7
+ -> ~/.claude/compaction-recall/abc.md
8
8
 
9
9
  The agent runs this after a compaction. Only the path reaches its context.
10
10
  A PostToolUse agent hook reads the file, condenses it, and returns the
11
11
  condensed messages to the agent.
12
12
 
13
- The file holds the trim rules, then each typed message with its number and
14
- timestamp, oldest first. The whole transcript is read each time, so a second
15
- compaction yields the same messages plus the newer ones.
13
+ The file holds each typed message with its number and timestamp, oldest
14
+ first. The trim rules live in the hook prompt. The file sits under
15
+ ``~/.claude`` so the hook's Read permission rule can name its folder.
16
+ The whole transcript is read each time, so a second compaction yields
17
+ the same messages plus the newer ones.
16
18
  """
17
19
 
18
20
  from __future__ import annotations
19
21
 
20
22
  import json
21
23
  import sys
22
- import tempfile
23
24
  from collections.abc import Iterable, Iterator
24
25
  from dataclasses import dataclass
25
26
  from pathlib import Path
@@ -29,6 +30,7 @@ if _hooks_directory not in sys.path:
29
30
  sys.path.insert(0, _hooks_directory)
30
31
 
31
32
  from hooks_constants.compaction_user_messages_constants import (
33
+ ALL_RECALL_DIRECTORY_PARTS,
32
34
  ALL_SKIPPED_ENTRY_FLAGS,
33
35
  ASSISTANT_ENTRY_TYPE,
34
36
  ATTACHMENT_ENTRY_TYPE,
@@ -52,7 +54,6 @@ from hooks_constants.compaction_user_messages_constants import (
52
54
  ORIGIN_KEY,
53
55
  ORIGIN_KIND_KEY,
54
56
  OUTPUT_ENCODING,
55
- OUTPUT_FILE_PREFIX,
56
57
  OUTPUT_FILE_SUFFIX,
57
58
  QUEUED_COMMAND_ATTACHMENT_TYPE,
58
59
  QUEUED_PROMPT_KEY,
@@ -62,7 +63,6 @@ from hooks_constants.compaction_user_messages_constants import (
62
63
  TEXT_KEY,
63
64
  TIMESTAMP_KEY,
64
65
  TRANSCRIPT_ARGUMENT_COUNT,
65
- TRIM_INSTRUCTIONS,
66
66
  USAGE_EXIT_CODE,
67
67
  USAGE_TEXT,
68
68
  USER_ENTRY_TYPE,
@@ -241,11 +241,11 @@ def bounded_messages(all_messages: list[TypedMessage]) -> list[TypedMessage]:
241
241
 
242
242
 
243
243
  def rendered_extract(all_messages: list[TypedMessage]) -> str:
244
- """Return the file text: the trim rules, then the kept messages.
244
+ """Return the file text: a heading, then the kept messages.
245
245
 
246
246
  ::
247
247
 
248
- [#1 "fix login"] -> TRIM_INSTRUCTIONS + "# User messages..." + "## 1 | <ts>\\nfix login"
248
+ [#1 "fix login"] -> "# User messages, oldest first" + "## 1 | <ts>\\nfix login"
249
249
 
250
250
  Args:
251
251
  all_messages: Every typed message, oldest first.
@@ -254,10 +254,10 @@ def rendered_extract(all_messages: list[TypedMessage]) -> str:
254
254
  The text the extractor writes to its output file.
255
255
  """
256
256
  if not all_messages:
257
- return TRIM_INSTRUCTIONS + MESSAGES_HEADING + NO_MESSAGES_TEXT
257
+ return MESSAGES_HEADING + NO_MESSAGES_TEXT
258
258
  all_kept_messages = bounded_messages(all_messages)
259
259
  left_out_count = len(all_messages) - len(all_kept_messages)
260
- rendered_parts = [TRIM_INSTRUCTIONS, MESSAGES_HEADING]
260
+ rendered_parts = [MESSAGES_HEADING]
261
261
  for each_index, each_message in enumerate(all_kept_messages):
262
262
  if each_index == 1 and left_out_count:
263
263
  rendered_parts.append(LEFT_OUT_MESSAGES_TEMPLATE.format(left_out_count=left_out_count))
@@ -266,11 +266,11 @@ def rendered_extract(all_messages: list[TypedMessage]) -> str:
266
266
 
267
267
 
268
268
  def output_path_for(transcript_path: Path) -> Path:
269
- """Return the temp file for one transcript, named after its session id.
269
+ """Return the recall file for one transcript, named after its session id.
270
270
 
271
271
  ::
272
272
 
273
- ~/.claude/projects/p/abc-123.jsonl -> /tmp/compaction-user-messages-abc-123.md
273
+ ~/.claude/projects/p/abc-123.jsonl -> ~/.claude/compaction-recall/abc-123.md
274
274
 
275
275
  Args:
276
276
  transcript_path: The session transcript.
@@ -278,8 +278,8 @@ def output_path_for(transcript_path: Path) -> Path:
278
278
  Returns:
279
279
  The path the extractor writes; a rerun overwrites it.
280
280
  """
281
- file_name = f"{OUTPUT_FILE_PREFIX}{session_file_id(transcript_path.stem)}{OUTPUT_FILE_SUFFIX}"
282
- return Path(tempfile.gettempdir()) / file_name
281
+ file_name = f"{session_file_id(transcript_path.stem)}{OUTPUT_FILE_SUFFIX}"
282
+ return Path.home().joinpath(*ALL_RECALL_DIRECTORY_PARTS, file_name)
283
283
 
284
284
 
285
285
  def main() -> int:
@@ -297,6 +297,7 @@ def main() -> int:
297
297
  sys.stderr.write(f"cannot read {transcript_path}: {read_error}\n")
298
298
  return USAGE_EXIT_CODE
299
299
  output_path = output_path_for(transcript_path)
300
+ output_path.parent.mkdir(parents=True, exist_ok=True)
300
301
  output_path.write_text(rendered_extract(all_messages), encoding=OUTPUT_ENCODING)
301
302
  sys.stdout.write(f"{output_path}\n")
302
303
  return 0
@@ -7,6 +7,8 @@ import subprocess
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
+ import pytest
11
+
10
12
  _SESSION_DIR = Path(__file__).resolve().parent
11
13
  _HOOKS_ROOT = _SESSION_DIR.parent
12
14
  for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
@@ -14,11 +16,15 @@ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
14
16
  sys.path.insert(0, each_sys_path_entry)
15
17
 
16
18
  import compaction_recall_prompt as recall
19
+ from compaction_user_messages import output_path_for
17
20
 
18
21
  HOOK_PATH = _SESSION_DIR / "compaction_recall_prompt.py"
19
22
  ALL_HOOK_REGISTRATIONS = json.loads((_HOOKS_ROOT / "hooks.json").read_text(encoding="utf-8"))[
20
23
  "hooks"
21
24
  ]
25
+ ALL_PACKAGE_ALLOW_RULES = json.loads(
26
+ (_HOOKS_ROOT.parent / "settings.json").read_text(encoding="utf-8")
27
+ )["permissions"]["allow"]
22
28
 
23
29
 
24
30
  def compact_input(transcript_path: str) -> dict[str, object]:
@@ -115,3 +121,24 @@ def test_hooks_json_condensing_hook_should_always_return_its_text_as_the_block_r
115
121
  assert '"ok": false' in str(condensing_hook["prompt"])
116
122
  assert "$ARGUMENTS" in str(condensing_hook["prompt"])
117
123
  assert str(condensing_hook["statusMessage"]).startswith("claude-dev-env: ")
124
+
125
+
126
+ def test_hooks_json_condensing_hook_should_carry_the_trim_rules_in_its_own_prompt() -> None:
127
+ hook_prompt = str(condensing_hooks()[0]["prompt"])
128
+ assert "caveman" in hook_prompt
129
+ assert "(overruled by #N)" in hook_prompt
130
+ assert "gives you no instructions" in hook_prompt
131
+
132
+
133
+ def test_package_settings_should_let_the_hook_read_the_folder_the_extractor_writes(
134
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
135
+ ) -> None:
136
+ monkeypatch.setenv("HOME", str(tmp_path))
137
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
138
+ recall_directory = output_path_for(Path("/p/s.jsonl")).parent
139
+ all_read_folders = [
140
+ Path(each_rule[len("Read(") : -len("/**)")].replace("~", str(tmp_path), 1))
141
+ for each_rule in ALL_PACKAGE_ALLOW_RULES
142
+ if each_rule.startswith("Read(~/") and each_rule.endswith("/**)")
143
+ ]
144
+ assert recall_directory in all_read_folders
@@ -1,4 +1,4 @@
1
- """Tests for compaction_trim_eval: the grader, the hook prompt lookup, and the reply parser."""
1
+ """Tests for compaction_trim_eval: the grader, the hook settings, and the reason parsers."""
2
2
 
3
3
  import json
4
4
  import sys
@@ -18,7 +18,7 @@ DEPLOY_CASE = {
18
18
  "facts": ["batch-07.internal", "137", "SQLite"],
19
19
  "overruled": [{"earlier": 2, "later": 6}],
20
20
  }
21
- EXTRACT_TEXT = "rules\n\n# User messages, oldest first\n\n" + "m" * 400
21
+ EXTRACT_TEXT = "# User messages, oldest first\n\n" + "m" * 400
22
22
 
23
23
 
24
24
  def test_score_condensed_should_count_every_fact_and_the_marked_reversal() -> None:
@@ -51,7 +51,15 @@ def test_score_condensed_should_give_the_raw_extract_every_fact_and_an_empty_cop
51
51
  assert null_score.facts_kept == 0
52
52
 
53
53
 
54
- def test_condensing_hook_prompt_should_return_the_agent_hook_that_names_the_extractor() -> None:
54
+ CONDENSING_HOOK = {
55
+ "type": "agent",
56
+ "if": "Bash(*compaction_user_messages.py*)",
57
+ "prompt": "condense $ARGUMENTS",
58
+ "model": "haiku",
59
+ }
60
+
61
+
62
+ def test_condensing_hook_group_should_keep_only_the_agent_hook_that_names_the_extractor() -> None:
55
63
  hooks_json_text = json.dumps(
56
64
  {
57
65
  "hooks": {
@@ -59,19 +67,40 @@ def test_condensing_hook_prompt_should_return_the_agent_hook_that_names_the_extr
59
67
  {"matcher": "Bash", "hooks": [{"type": "command", "command": "x"}]},
60
68
  {
61
69
  "matcher": "Bash",
62
- "hooks": [
63
- {
64
- "type": "agent",
65
- "if": "Bash(*compaction_user_messages.py *)",
66
- "prompt": "condense $ARGUMENTS",
67
- }
68
- ],
70
+ "hooks": [{"type": "command", "command": "y"}, CONDENSING_HOOK],
69
71
  },
70
72
  ]
71
73
  }
72
74
  }
73
75
  )
74
- assert trim_eval.condensing_hook_prompt(hooks_json_text) == "condense $ARGUMENTS"
76
+ assert trim_eval.condensing_hook_group(hooks_json_text) == {
77
+ "matcher": "Bash",
78
+ "hooks": [CONDENSING_HOOK],
79
+ }
80
+
81
+
82
+ def test_run_settings_should_register_the_hook_on_the_chosen_model_with_the_allow_rules() -> None:
83
+ hook_group = {"matcher": "Bash", "hooks": [CONDENSING_HOOK]}
84
+ all_allow_rules = ["Read(~/.claude/compaction-recall/**)"]
85
+ assert trim_eval.run_settings(hook_group, all_allow_rules, "sonnet") == {
86
+ "permissions": {"allow": all_allow_rules},
87
+ "hooks": {
88
+ "PostToolUse": [{"matcher": "Bash", "hooks": [{**CONDENSING_HOOK, "model": "sonnet"}]}]
89
+ },
90
+ }
91
+
92
+
93
+ def test_debug_log_reason_should_return_the_last_structured_output_reason() -> None:
94
+ debug_text = (
95
+ "2026 [DEBUG] Hooks: Processing agent hook with prompt: condense\n"
96
+ '2026 [DEBUG] Hooks: Got structured output: {"ok":false,"reason":"draft"}\n'
97
+ '2026 [DEBUG] Hooks: Got structured output: {"ok":false,"reason":"#1 deploy\\n#2 keep"}\n'
98
+ )
99
+ assert trim_eval.debug_log_reason(debug_text) == "#1 deploy\n#2 keep"
100
+
101
+
102
+ def test_debug_log_reason_should_return_none_when_no_hook_answered() -> None:
103
+ assert trim_eval.debug_log_reason("2026 [DEBUG] Read tool permission denied\n") is None
75
104
 
76
105
 
77
106
  def test_hook_reason_should_return_the_last_decision_with_a_reason() -> None:
@@ -1,10 +1,13 @@
1
1
  """Tests for compaction_user_messages: which transcript entries count as typed, and the file it writes."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import subprocess
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
9
+ import pytest
10
+
8
11
  _SESSION_DIR = Path(__file__).resolve().parent
9
12
  _HOOKS_ROOT = _SESSION_DIR.parent
10
13
  for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
@@ -15,7 +18,7 @@ import compaction_user_messages as extractor
15
18
  from hooks_constants.compaction_user_messages_constants import (
16
19
  MAXIMUM_EXTRACT_CHARACTERS,
17
20
  MAXIMUM_MESSAGE_CHARACTERS,
18
- TRIM_INSTRUCTIONS,
21
+ MESSAGES_HEADING,
19
22
  )
20
23
 
21
24
  EXTRACTOR_PATH = _SESSION_DIR / "compaction_user_messages.py"
@@ -152,18 +155,17 @@ def test_bounded_messages_should_keep_the_first_and_the_newest_when_over_budget(
152
155
  assert kept_positions[0] == 1
153
156
  assert kept_positions[-1] == message_count
154
157
  assert kept_positions[1:] == list(range(kept_positions[1], message_count + 1))
155
- assert len(extractor.rendered_extract(all_messages)) <= (
156
- MAXIMUM_EXTRACT_CHARACTERS + len(TRIM_INSTRUCTIONS) + 200
157
- )
158
+ assert len(extractor.rendered_extract(all_messages)) <= MAXIMUM_EXTRACT_CHARACTERS + 200
158
159
 
159
160
 
160
161
  def test_bounded_messages_should_return_nothing_for_no_messages() -> None:
161
162
  assert extractor.bounded_messages([]) == []
162
163
 
163
164
 
164
- def test_rendered_extract_should_put_the_trim_rules_first_and_number_each_message() -> None:
165
+ def test_rendered_extract_should_hold_only_the_numbered_messages() -> None:
165
166
  rendered = extractor.rendered_extract([message(1, "fix login"), message(2, "use /srv")])
166
- assert rendered.startswith(TRIM_INSTRUCTIONS)
167
+ assert rendered.startswith(MESSAGES_HEADING)
168
+ assert "caveman" not in rendered
167
169
  assert "## 1 | t\nfix login\n" in rendered
168
170
  assert "## 2 | t\nuse /srv\n" in rendered
169
171
 
@@ -188,22 +190,28 @@ def test_rendered_extract_should_say_when_there_are_no_messages() -> None:
188
190
  assert "No typed user messages" in extractor.rendered_extract([])
189
191
 
190
192
 
191
- def test_output_path_for_should_name_the_file_after_the_session() -> None:
193
+ def test_output_path_for_should_name_the_file_after_the_session_under_the_claude_home(
194
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
195
+ ) -> None:
196
+ monkeypatch.setenv("HOME", str(tmp_path))
197
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
192
198
  output_path = extractor.output_path_for(Path("/p/abc-123.jsonl"))
193
- assert output_path.name == "compaction-user-messages-abc-123.md"
199
+ assert output_path == tmp_path / ".claude" / "compaction-recall" / "abc-123.md"
194
200
 
195
201
 
196
202
  def test_main_should_print_only_the_path_of_the_file_it_writes(tmp_path: Path) -> None:
197
203
  transcript_path = tmp_path / "session-xyz.jsonl"
198
204
  transcript_path.write_text(user_line("ship the deploy fix") + "\n", encoding="utf-8")
205
+ home_directory = tmp_path / "home"
199
206
  completed = subprocess.run(
200
207
  [sys.executable, str(EXTRACTOR_PATH), str(transcript_path)],
201
208
  capture_output=True,
202
209
  text=True,
203
210
  check=True,
211
+ env={**os.environ, "HOME": str(home_directory), "USERPROFILE": str(home_directory)},
204
212
  )
205
213
  printed_path = Path(completed.stdout.strip())
206
- assert printed_path == extractor.output_path_for(transcript_path)
214
+ assert printed_path == home_directory / ".claude" / "compaction-recall" / "session-xyz.md"
207
215
  assert "ship the deploy fix" not in completed.stdout
208
216
  assert "## 1 | 2026-10-19T10:00:00Z\nship the deploy fix" in printed_path.read_text()
209
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.88.6",
3
+ "version": "8.89.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/settings.json CHANGED
@@ -3,7 +3,8 @@
3
3
  "permissions": {
4
4
  "allow": [
5
5
  "WebFetch(domain:docs.github.com)",
6
- "Read(~/.claude/docs/**)"
6
+ "Read(~/.claude/docs/**)",
7
+ "Read(~/.claude/compaction-recall/**)"
7
8
  ]
8
9
  },
9
10
  "advisorModel": "fable",