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.
- package/bin/install-constants.mjs +3 -0
- package/bin/install.cursor-rules.test.mjs +12 -1
- package/bin/install.mjs +18 -8
- package/bin/install.settings-defaults.test.mjs +5 -1
- package/hooks/advisory/owned_pr_review_loop.py +365 -0
- package/hooks/advisory/test_owned_pr_review_loop.py +369 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/session-context.md +3 -3
- package/hooks/hooks.json +11 -1
- package/hooks/hooks_constants/compaction_user_messages_constants.py +14 -22
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +120 -0
- package/hooks/session/compaction_trim_eval.py +101 -52
- package/hooks/session/compaction_user_messages.py +16 -15
- package/hooks/session/test_compaction_recall_prompt.py +27 -0
- package/hooks/session/test_compaction_trim_eval.py +40 -11
- package/hooks/session/test_compaction_user_messages.py +17 -9
- package/package.json +1 -1
- package/settings.json +2 -1
|
@@ -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.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
47
|
+
from compaction_user_messages import rendered_extract, typed_user_messages
|
|
45
48
|
from hooks_constants.compaction_user_messages_constants import (
|
|
46
|
-
|
|
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
|
|
149
|
-
"""Return the
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
"--
|
|
205
|
-
|
|
206
|
-
"--
|
|
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
|
-
"--
|
|
209
|
-
str(
|
|
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
|
-
|
|
215
|
-
|
|
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=
|
|
259
|
+
cwd=run_directory,
|
|
220
260
|
check=False,
|
|
221
261
|
)
|
|
222
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
-> /
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
244
|
+
"""Return the file text: a heading, then the kept messages.
|
|
245
245
|
|
|
246
246
|
::
|
|
247
247
|
|
|
248
|
-
[#1 "fix 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
|
|
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 = [
|
|
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
|
|
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 -> /
|
|
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"{
|
|
282
|
-
return Path(
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 ==
|
|
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