claude-dev-env 8.88.5 → 8.88.6
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/.agents/skills/efficient-review/tools/config/test_efficient_review_constants.py +23 -0
- package/.agents/skills/efficient-review/tools/test_efficient_review.py +33 -0
- package/.agents/skills/efficient-review/tools/test_efficient_review_plan.py +27 -0
- package/hooks/blocking/test_verify_before_acting.py +1 -1
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +18 -0
- package/hooks/hooks_constants/compaction_user_messages_constants.py +101 -0
- package/hooks/hooks_constants/test_haiku_spawn_slim_gate_constants.py +18 -0
- package/hooks/session/compaction_recall_prompt.py +90 -0
- package/hooks/session/compaction_trim_eval.py +343 -0
- package/hooks/session/compaction_user_messages.py +306 -0
- package/hooks/session/fixtures/compaction_user_messages/deploy_session.jsonl +17 -0
- package/hooks/session/fixtures/compaction_user_messages/long_session.jsonl +41 -0
- package/hooks/session/fixtures/compaction_user_messages/one_compaction.jsonl +7 -0
- package/hooks/session/fixtures/compaction_user_messages/recall_request_session.jsonl +4 -0
- package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +47 -0
- package/hooks/session/test_compaction_recall_prompt.py +117 -0
- package/hooks/session/test_compaction_trim_eval.py +86 -0
- package/hooks/session/test_compaction_user_messages.py +220 -0
- package/package.json +1 -1
- package/scripts/ci/scratch-home-install.mjs +3 -2
- package/scripts/ci/scratch-home-install.test.mjs +23 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:18Z", "message": {"role": "user", "content": "we are cleaning up the photo importer in tools/photo_import/. goal: imports under 90 seconds for a 2,000 photo card"}, "origin": {"kind": "human"}}
|
|
2
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:19Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
3
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:20Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
4
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:21Z", "message": {"role": "user", "content": "sounds good"}, "origin": {"kind": "human"}}
|
|
5
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:22Z", "message": {"role": "user", "content": "the slow part is the EXIF read in exif_reader.py, it opens each file twice"}, "origin": {"kind": "human"}}
|
|
6
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:23Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
7
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:24Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
8
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:25Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
9
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:26Z", "message": {"role": "user", "content": "yes go ahead"}, "origin": {"kind": "human"}}
|
|
10
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:27Z", "message": {"role": "user", "content": "do not add new dependencies, pillow is already pinned at 10.4.0"}, "origin": {"kind": "human"}}
|
|
11
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:28Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
12
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:29Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
13
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:30Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
14
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:31Z", "message": {"role": "user", "content": "ok"}, "origin": {"kind": "human"}}
|
|
15
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:32Z", "message": {"role": "user", "content": "skip RAW files with the .cr3 extension for now"}, "origin": {"kind": "human"}}
|
|
16
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:33Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
17
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:34Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
18
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:35Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
19
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:36Z", "message": {"role": "user", "content": "nice"}, "origin": {"kind": "human"}}
|
|
20
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:37Z", "message": {"role": "user", "content": "the test card lives at /mnt/cards/sample_2000"}, "origin": {"kind": "human"}}
|
|
21
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:38Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
22
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:39Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
23
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:40Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
24
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:41Z", "message": {"role": "user", "content": "keep going"}, "origin": {"kind": "human"}}
|
|
25
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:42Z", "message": {"role": "user", "content": "use 6 worker threads, the NAS chokes above that"}, "origin": {"kind": "human"}}
|
|
26
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:43Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
27
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:44Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
28
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:45Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
29
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:46Z", "message": {"role": "user", "content": "sure"}, "origin": {"kind": "human"}}
|
|
30
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:47Z", "message": {"role": "user", "content": "write the timing to logs/import_timing.csv with one row per run"}, "origin": {"kind": "human"}}
|
|
31
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:48Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
32
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:49Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
33
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:50Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
34
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:51Z", "message": {"role": "user", "content": "great, continue"}, "origin": {"kind": "human"}}
|
|
35
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:52Z", "message": {"role": "user", "content": "change that: make it 4 worker threads, 6 still froze the NAS last night"}, "origin": {"kind": "human"}}
|
|
36
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:53Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
37
|
+
{"type": "assistant", "timestamp": "2026-10-19T10:00:54Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Working on it."}]}}
|
|
38
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:55Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ran tests: 41 passed"}]}}
|
|
39
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:56Z", "message": {"role": "user", "content": "makes sense"}, "origin": {"kind": "human"}}
|
|
40
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:57Z", "message": {"role": "user", "content": "name the branch photo-import-speed"}, "origin": {"kind": "human"}}
|
|
41
|
+
{"type": "user", "timestamp": "2026-10-19T10:00:58Z", "message": {"role": "user", "content": "helper output"}, "isSidechain": true}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"type": "user", "message": {"role": "user", "content": "Note these facts for later: the deploy path is /srv/alpha/release-42 and the retry limit is 17. Reply only: noted."}, "isSidechain": false, "timestamp": "2026-10-19T14:18:25.345Z"}
|
|
2
|
+
{"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "noted."}]}, "isSidechain": false, "timestamp": "2026-10-19T14:18:25.997Z"}
|
|
3
|
+
{"type": "system", "subtype": "compact_boundary", "isMeta": false, "isSidechain": false, "timestamp": "2026-10-19T14:18:41.560Z", "content": "Conversation compacted"}
|
|
4
|
+
{"type": "user", "message": {"role": "user", "content": "This session is being continued from a previous conversation. Summary omitted from fixture."}, "isCompactSummary": true, "isSidechain": false, "timestamp": "2026-10-19T14:18:41.558Z"}
|
|
5
|
+
{"type": "user", "message": {"role": "user", "content": "<local-command-caveat>The command below was run directly in Claude Code, not sent to you as a request, and its output goes straight to the user. It's recorded here as context for later messages.</local-command-caveat>"}, "isMeta": true, "isSidechain": false, "timestamp": "2026-10-19T14:18:38.047Z"}
|
|
6
|
+
{"type": "user", "message": {"role": "user", "content": "<command-name>/compact</command-name>\n <command-message>compact</command-message>\n <command-args></command-args>"}, "isSidechain": false, "timestamp": "2026-10-19T14:18:38.047Z"}
|
|
7
|
+
{"type": "user", "message": {"role": "user", "content": "<local-command-stdout>Compacted (ctrl+o to see full summary)</local-command-stdout>"}, "isSidechain": false, "timestamp": "2026-10-19T14:18:41.746Z"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{"type": "user", "message": {"role": "user", "content": "<command-message>deepen</command-message>\n<command-name>/deepen</command-name>\n<command-args>i'd like my agents to be able to have better post-compaction behavior; i feel that there is a strong potential benefit in having a post-compact hook which runs a tool that -reads- that session's chat transcript, and this tool simply returns the user's messages to that agent. example is attached.\n\ni do believe that we should probably 'lighten the load' by having some other layer - for example perhaps this is a prompt hook that is aligned with it. the prompt hook is simply a caveman style return of the user's messages; so we run our tool, it returns the input to that prompt hook (raw user 'message' input), and then the agent which receives that is instructed to trim it down without losing substance; ie: caveman style messages. and this is the final result that's returned to our agent.</command-args>"}, "isSidechain": false, "origin": {"kind": "human"}, "timestamp": "2026-10-19T14:11:07.044Z"}
|
|
2
|
+
{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Base directory for this skill: ~/.claude/skills/deepen\n\n# deepen\n\nOn a first task, `/deepen` plus a task, or a routine wake that has work, read DeepStack intake and follow it: [intake](https://github.com/jl-cmd-projects/DeepStack/blob/main/plugins/deepstack/skills/deepstack-mode/references/intake.md).\n\nSkip when the prompt already carries `CDE_DEEPEN:v1`, when the text is a thanks or yes/no or status line, when it is a slash command other than `/deepen`, when it is a `/loop` or helper report, or when it is a spawn rewrite that already starts with the poteto-mode prefix.\n\nA prompt that is only `/deepen` with no task is skipped. `/deepen` plus a task deepens the remainder.\n\nThe deepening pass writes the worker prompt and stops. The worker runs that prompt.\n\nWhen you spawn a helper to fill the four answers, that helper's prompt starts with `CDE_DEEPEN_AGENT` so the first-task hook leaves it alone.\n\n## Hosts\n\n- Claude desktop and CLI: a `prompt.submit` mod rewrites the visible text. The Python `UserPromptSubmit` hook is silent when that mod is on outside a cloud session.\n- Claude cloud and Codex local: the Python hook injects `additionalContext`. Command hooks cannot replace the prompt string.\n- Cursor Cloud: run DeepStack intake on the launch prompt. The host adapter for the three lines and the refusal lives with that host. This package keeps the gate.\n- Codex cloud orchestration skips command hooks. Local Codex only.\n\n\nARGUMENTS: i'd like my agents to be able to have better post-compaction behavior; i feel that there is a strong potential benefit in having a post-compact hook which runs a tool that -reads- that session's chat transcript, and this tool simply returns the user's messages to that agent. example is attached.\n\ni do believe that we should probably 'lighten the load' by having some other layer - for example perhaps this is a prompt hook that is aligned with it. the prompt hook is simply a caveman style return of the user's messages; so we run our tool, it returns the input to that prompt hook (raw user 'message' input), and then the agent which receives that is instructed to trim it down without losing substance; ie: caveman style messages. and this is the final result that's returned to our agent."}]}, "isMeta": true, "isSidechain": false, "timestamp": "2026-10-19T14:11:07.044Z"}
|
|
3
|
+
{"type": "user", "message": {"role": "user", "content": [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}, {"text": "here.\n\nand i dont buy it. prompot hooks can definitely return text or values? i'm fairly certain ihave that already in my existing hooks.", "type": "text"}]}, "isSidechain": false, "origin": {"kind": "human"}, "timestamp": "2026-10-19T14:14:31.507Z"}
|
|
4
|
+
{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Image: source: /tmp/claude-0/-workspace/d1c9e10e-7af1-51e5-8956-15fde41ab886/images/1.png]"}]}, "isMeta": true, "isSidechain": false, "timestamp": "2026-10-19T14:14:31.507Z"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"transcript": "deploy_session.jsonl",
|
|
4
|
+
"facts": [
|
|
5
|
+
"/deepen",
|
|
6
|
+
"batch-07.internal",
|
|
7
|
+
"02:15 UTC",
|
|
8
|
+
"billing.invoices_v3",
|
|
9
|
+
"packages/billing/legacy_export.py",
|
|
10
|
+
"Monday",
|
|
11
|
+
"SQLite",
|
|
12
|
+
"Q3",
|
|
13
|
+
"137",
|
|
14
|
+
"4 attempts",
|
|
15
|
+
"30s",
|
|
16
|
+
"https://github.com/acme/billing/pull/812",
|
|
17
|
+
"Dana"
|
|
18
|
+
],
|
|
19
|
+
"overruled": [{"earlier": 2, "later": 6}]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"transcript": "long_session.jsonl",
|
|
23
|
+
"facts": [
|
|
24
|
+
"tools/photo_import/",
|
|
25
|
+
"90 seconds",
|
|
26
|
+
"2,000",
|
|
27
|
+
"exif_reader.py",
|
|
28
|
+
"10.4.0",
|
|
29
|
+
".cr3",
|
|
30
|
+
"/mnt/cards/sample_2000",
|
|
31
|
+
"logs/import_timing.csv",
|
|
32
|
+
"4 worker threads",
|
|
33
|
+
"photo-import-speed"
|
|
34
|
+
],
|
|
35
|
+
"overruled": [{"earlier": 11, "later": 15}]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"transcript": "recall_request_session.jsonl",
|
|
39
|
+
"facts": [
|
|
40
|
+
"/deepen",
|
|
41
|
+
"transcript",
|
|
42
|
+
"caveman",
|
|
43
|
+
"prompt hook"
|
|
44
|
+
],
|
|
45
|
+
"overruled": []
|
|
46
|
+
}
|
|
47
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for compaction_recall_prompt and the hooks.json entries that carry its recall."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
11
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
12
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
13
|
+
if each_sys_path_entry not in sys.path:
|
|
14
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
15
|
+
|
|
16
|
+
import compaction_recall_prompt as recall
|
|
17
|
+
|
|
18
|
+
HOOK_PATH = _SESSION_DIR / "compaction_recall_prompt.py"
|
|
19
|
+
ALL_HOOK_REGISTRATIONS = json.loads((_HOOKS_ROOT / "hooks.json").read_text(encoding="utf-8"))[
|
|
20
|
+
"hooks"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compact_input(transcript_path: str) -> dict[str, object]:
|
|
25
|
+
return {
|
|
26
|
+
"hook_event_name": "SessionStart",
|
|
27
|
+
"source": "compact",
|
|
28
|
+
"transcript_path": transcript_path,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def recall_command(transcript_path: str) -> str:
|
|
33
|
+
context_text = recall.recall_context(compact_input(transcript_path))
|
|
34
|
+
assert context_text is not None
|
|
35
|
+
return next(line for line in context_text.splitlines() if "compaction_user_messages.py" in line)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_recall_context_should_name_a_command_that_writes_the_users_messages(
|
|
39
|
+
tmp_path: Path,
|
|
40
|
+
) -> None:
|
|
41
|
+
transcript_path = tmp_path / "it's a session.jsonl"
|
|
42
|
+
transcript_path.write_text(
|
|
43
|
+
json.dumps({"type": "user", "message": {"content": "keep port 8443"}}) + "\n",
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
)
|
|
46
|
+
completed = subprocess.run(
|
|
47
|
+
shlex.split(recall_command(str(transcript_path))),
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
assert "keep port 8443" in Path(completed.stdout.strip()).read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_recall_context_should_stay_quiet_outside_a_compaction() -> None:
|
|
56
|
+
assert recall.recall_context({**compact_input("/t.jsonl"), "source": "startup"}) is None
|
|
57
|
+
assert recall.recall_context({**compact_input("/t.jsonl"), "hook_event_name": "Stop"}) is None
|
|
58
|
+
assert recall.recall_context({"hook_event_name": "SessionStart", "source": "compact"}) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_main_should_print_the_context_as_session_start_output() -> None:
|
|
62
|
+
completed = subprocess.run(
|
|
63
|
+
[sys.executable, str(HOOK_PATH)],
|
|
64
|
+
input=json.dumps(compact_input("/t/abc.jsonl")),
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=True,
|
|
68
|
+
)
|
|
69
|
+
hook_output = json.loads(completed.stdout)["hookSpecificOutput"]
|
|
70
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
71
|
+
assert "compaction_user_messages.py" in hook_output["additionalContext"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_hooks_json_should_run_the_recall_prompt_after_every_compaction() -> None:
|
|
75
|
+
all_compact_commands = [
|
|
76
|
+
each_hook.get("command", "")
|
|
77
|
+
for each_group in ALL_HOOK_REGISTRATIONS["SessionStart"]
|
|
78
|
+
if each_group.get("matcher") == "compact"
|
|
79
|
+
for each_hook in each_group["hooks"]
|
|
80
|
+
]
|
|
81
|
+
assert any(
|
|
82
|
+
each_command.endswith("/hooks/session/compaction_recall_prompt.py")
|
|
83
|
+
for each_command in all_compact_commands
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def condensing_hooks() -> list[dict[str, object]]:
|
|
88
|
+
return [
|
|
89
|
+
each_hook
|
|
90
|
+
for each_group in ALL_HOOK_REGISTRATIONS["PostToolUse"]
|
|
91
|
+
if each_group.get("matcher") == "Bash"
|
|
92
|
+
for each_hook in each_group["hooks"]
|
|
93
|
+
if each_hook.get("type") == "agent"
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_hooks_json_condensing_hook_should_match_the_command_the_recall_names() -> None:
|
|
98
|
+
all_hooks = condensing_hooks()
|
|
99
|
+
assert len(all_hooks) == 1
|
|
100
|
+
if_rule = str(all_hooks[0]["if"])
|
|
101
|
+
assert if_rule.startswith("Bash(") and if_rule.endswith(")")
|
|
102
|
+
command_pattern = if_rule[len("Bash(") : -1]
|
|
103
|
+
assert fnmatch.fnmatchcase(
|
|
104
|
+
recall_command("/home/u/.claude/projects/p/s.jsonl"), command_pattern
|
|
105
|
+
)
|
|
106
|
+
spaced_home_command = shlex.join(
|
|
107
|
+
[sys.executable, "/home/a b/.claude/hooks/session/compaction_user_messages.py", "/t.jsonl"]
|
|
108
|
+
)
|
|
109
|
+
assert fnmatch.fnmatchcase(spaced_home_command, command_pattern)
|
|
110
|
+
assert not fnmatch.fnmatchcase("python3 other_script.py /t.jsonl", command_pattern)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_hooks_json_condensing_hook_should_always_return_its_text_as_the_block_reason() -> None:
|
|
114
|
+
condensing_hook = condensing_hooks()[0]
|
|
115
|
+
assert '"ok": false' in str(condensing_hook["prompt"])
|
|
116
|
+
assert "$ARGUMENTS" in str(condensing_hook["prompt"])
|
|
117
|
+
assert str(condensing_hook["statusMessage"]).startswith("claude-dev-env: ")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tests for compaction_trim_eval: the grader, the hook prompt lookup, and the reply parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
8
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
9
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
10
|
+
if each_sys_path_entry not in sys.path:
|
|
11
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
12
|
+
|
|
13
|
+
import compaction_trim_eval as trim_eval
|
|
14
|
+
from compaction_user_messages import rendered_extract, typed_user_messages
|
|
15
|
+
|
|
16
|
+
DEPLOY_CASE = {
|
|
17
|
+
"transcript": "deploy_session.jsonl",
|
|
18
|
+
"facts": ["batch-07.internal", "137", "SQLite"],
|
|
19
|
+
"overruled": [{"earlier": 2, "later": 6}],
|
|
20
|
+
}
|
|
21
|
+
EXTRACT_TEXT = "rules\n\n# User messages, oldest first\n\n" + "m" * 400
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_score_condensed_should_count_every_fact_and_the_marked_reversal() -> None:
|
|
25
|
+
condensed_text = (
|
|
26
|
+
"#1 deploy to BATCH-07.internal\n#2 keep Postgres (overruled by #6)\n"
|
|
27
|
+
"#5 exit code 137\n#6 switch to SQLite\n"
|
|
28
|
+
)
|
|
29
|
+
case_score = trim_eval.score_condensed(condensed_text, DEPLOY_CASE, EXTRACT_TEXT)
|
|
30
|
+
assert (case_score.facts_kept, case_score.facts_total) == (3, 3)
|
|
31
|
+
assert (case_score.overruled_marked, case_score.overruled_total) == (1, 1)
|
|
32
|
+
assert case_score.compression_ratio == len("m" * 400) / len(condensed_text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_score_condensed_should_score_a_copy_that_drops_facts_lower() -> None:
|
|
36
|
+
lossy_text = "#1 deploy\n#2 keep Postgres\n#6 switch db\n"
|
|
37
|
+
case_score = trim_eval.score_condensed(lossy_text, DEPLOY_CASE, EXTRACT_TEXT)
|
|
38
|
+
assert case_score.facts_kept == 0
|
|
39
|
+
assert case_score.overruled_marked == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_score_condensed_should_give_the_raw_extract_every_fact_and_an_empty_copy_none() -> None:
|
|
43
|
+
fixture_directory = _SESSION_DIR / "fixtures" / "compaction_user_messages"
|
|
44
|
+
all_cases = json.loads((fixture_directory / "trim_cases.json").read_text(encoding="utf-8"))
|
|
45
|
+
for each_case in all_cases:
|
|
46
|
+
transcript_lines = (fixture_directory / each_case["transcript"]).read_text().splitlines()
|
|
47
|
+
extract_text = rendered_extract(typed_user_messages(transcript_lines))
|
|
48
|
+
oracle_score = trim_eval.score_condensed(extract_text, each_case, extract_text)
|
|
49
|
+
null_score = trim_eval.score_condensed("", each_case, extract_text)
|
|
50
|
+
assert oracle_score.facts_kept == oracle_score.facts_total
|
|
51
|
+
assert null_score.facts_kept == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_condensing_hook_prompt_should_return_the_agent_hook_that_names_the_extractor() -> None:
|
|
55
|
+
hooks_json_text = json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"hooks": {
|
|
58
|
+
"PostToolUse": [
|
|
59
|
+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "x"}]},
|
|
60
|
+
{
|
|
61
|
+
"matcher": "Bash",
|
|
62
|
+
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "agent",
|
|
65
|
+
"if": "Bash(*compaction_user_messages.py *)",
|
|
66
|
+
"prompt": "condense $ARGUMENTS",
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
assert trim_eval.condensing_hook_prompt(hooks_json_text) == "condense $ARGUMENTS"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_hook_reason_should_return_the_last_decision_with_a_reason() -> None:
|
|
78
|
+
model_reply = (
|
|
79
|
+
'Draft {"ok": false, "reason": "first"} then {"note": 1} and '
|
|
80
|
+
'{"ok": false, "reason": "#1 use {braces} here"}'
|
|
81
|
+
)
|
|
82
|
+
assert trim_eval.hook_reason(model_reply) == "#1 use {braces} here"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_hook_reason_should_return_none_when_the_reply_has_no_decision() -> None:
|
|
86
|
+
assert trim_eval.hook_reason("I could not read the file {broken") is None
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Tests for compaction_user_messages: which transcript entries count as typed, and the file it writes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
9
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
10
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
11
|
+
if each_sys_path_entry not in sys.path:
|
|
12
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
13
|
+
|
|
14
|
+
import compaction_user_messages as extractor
|
|
15
|
+
from hooks_constants.compaction_user_messages_constants import (
|
|
16
|
+
MAXIMUM_EXTRACT_CHARACTERS,
|
|
17
|
+
MAXIMUM_MESSAGE_CHARACTERS,
|
|
18
|
+
TRIM_INSTRUCTIONS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
EXTRACTOR_PATH = _SESSION_DIR / "compaction_user_messages.py"
|
|
22
|
+
FIXTURE_DIRECTORY = _SESSION_DIR / "fixtures" / "compaction_user_messages"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def user_line(content: object, **all_extra_fields: object) -> str:
|
|
26
|
+
return json.dumps(
|
|
27
|
+
{
|
|
28
|
+
"type": "user",
|
|
29
|
+
"timestamp": "2026-10-19T10:00:00Z",
|
|
30
|
+
"message": {"role": "user", "content": content},
|
|
31
|
+
**all_extra_fields,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def texts_of(all_lines: list[str]) -> list[str]:
|
|
37
|
+
return [each_message.text for each_message in extractor.typed_user_messages(all_lines)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_should_keep_plain_typed_text_with_position_and_timestamp() -> None:
|
|
41
|
+
all_messages = extractor.typed_user_messages(
|
|
42
|
+
[user_line("fix the login bug"), user_line(" use /srv/app ")]
|
|
43
|
+
)
|
|
44
|
+
assert [
|
|
45
|
+
(each_message.position, each_message.timestamp, each_message.text)
|
|
46
|
+
for each_message in all_messages
|
|
47
|
+
] == [
|
|
48
|
+
(1, "2026-10-19T10:00:00Z", "fix the login bug"),
|
|
49
|
+
(2, "2026-10-19T10:00:00Z", "use /srv/app"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_show_a_slash_command_as_name_and_typed_arguments() -> None:
|
|
54
|
+
command_text = (
|
|
55
|
+
"<command-message>deepen</command-message>\n<command-name>/deepen</command-name>\n"
|
|
56
|
+
"<command-args>make it faster</command-args>"
|
|
57
|
+
)
|
|
58
|
+
assert texts_of([user_line(command_text)]) == ["/deepen make it faster"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_leave_out_harness_and_agent_entries() -> None:
|
|
62
|
+
all_lines = [
|
|
63
|
+
user_line("skill body", isMeta=True),
|
|
64
|
+
user_line("summary", isCompactSummary=True),
|
|
65
|
+
user_line("helper prompt", isSidechain=True),
|
|
66
|
+
user_line("report", origin={"kind": "peer"}),
|
|
67
|
+
user_line([{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]),
|
|
68
|
+
user_line("<system-reminder>rules</system-reminder>"),
|
|
69
|
+
user_line("<local-command-stdout>Compacted</local-command-stdout>"),
|
|
70
|
+
json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}),
|
|
71
|
+
"not json",
|
|
72
|
+
user_line("typed", origin={"kind": "human"}),
|
|
73
|
+
]
|
|
74
|
+
assert texts_of(all_lines) == ["typed"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_should_keep_typed_text_that_opens_with_markup() -> None:
|
|
78
|
+
all_lines = [
|
|
79
|
+
user_line("<Button> does not render"),
|
|
80
|
+
user_line("<task-notification>done</task-notification>"),
|
|
81
|
+
]
|
|
82
|
+
assert texts_of(all_lines) == ["<Button> does not render"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_keep_an_interrupt_and_mark_an_image() -> None:
|
|
86
|
+
all_lines = [
|
|
87
|
+
user_line([{"type": "text", "text": "[Request interrupted by user]"}]),
|
|
88
|
+
user_line([{"type": "image", "source": {}}, {"type": "text", "text": "here."}]),
|
|
89
|
+
]
|
|
90
|
+
assert texts_of(all_lines) == ["[Request interrupted by user]", "[image] here."]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_should_count_a_queued_prompt_once_when_it_also_lands_as_a_user_entry() -> None:
|
|
94
|
+
queued_line = json.dumps(
|
|
95
|
+
{"type": "attachment", "attachment": {"type": "queued_command", "prompt": "check logs"}}
|
|
96
|
+
)
|
|
97
|
+
relay_line = json.dumps(
|
|
98
|
+
{
|
|
99
|
+
"type": "attachment",
|
|
100
|
+
"attachment": {"type": "queued_command", "prompt": "<agent-message>x"},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
assert texts_of([queued_line, user_line("check logs"), relay_line]) == ["check logs"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_should_keep_a_message_typed_again_after_the_assistant_replied() -> None:
|
|
107
|
+
assistant_line = json.dumps({"type": "assistant", "message": {"content": "done"}})
|
|
108
|
+
assert texts_of([user_line("yes"), assistant_line, user_line("yes")]) == ["yes", "yes"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_read_a_claude_code_transcript_across_a_compaction() -> None:
|
|
112
|
+
fixture_lines = (FIXTURE_DIRECTORY / "one_compaction.jsonl").read_text().splitlines()
|
|
113
|
+
assert texts_of(fixture_lines) == [
|
|
114
|
+
"Note these facts for later: the deploy path is /srv/alpha/release-42 and the "
|
|
115
|
+
"retry limit is 17. Reply only: noted.",
|
|
116
|
+
"/compact",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_converge_when_a_second_compaction_appends_to_the_transcript() -> None:
|
|
121
|
+
first_lines = (FIXTURE_DIRECTORY / "one_compaction.jsonl").read_text().splitlines()
|
|
122
|
+
second_lines = first_lines + [
|
|
123
|
+
user_line("now set the retry limit to 9"),
|
|
124
|
+
json.dumps({"type": "system", "subtype": "compact_boundary"}),
|
|
125
|
+
user_line("summary two", isCompactSummary=True),
|
|
126
|
+
user_line("<command-name>/compact</command-name>"),
|
|
127
|
+
]
|
|
128
|
+
first_messages = extractor.typed_user_messages(first_lines)
|
|
129
|
+
second_messages = extractor.typed_user_messages(second_lines)
|
|
130
|
+
assert second_messages[: len(first_messages)] == first_messages
|
|
131
|
+
assert [each_message.text for each_message in second_messages[len(first_messages) :]] == [
|
|
132
|
+
"now set the retry limit to 9",
|
|
133
|
+
"/compact",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def message(position: int, text: str) -> extractor.TypedMessage:
|
|
138
|
+
return extractor.TypedMessage(position=position, timestamp="t", text=text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_bounded_messages_should_keep_everything_that_fits() -> None:
|
|
142
|
+
all_messages = [message(1, "a"), message(2, "b"), message(3, "c")]
|
|
143
|
+
assert extractor.bounded_messages(all_messages) == all_messages
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_bounded_messages_should_keep_the_first_and_the_newest_when_over_budget() -> None:
|
|
147
|
+
long_text = "x" * (MAXIMUM_MESSAGE_CHARACTERS - 100)
|
|
148
|
+
message_count = 2 * MAXIMUM_EXTRACT_CHARACTERS // MAXIMUM_MESSAGE_CHARACTERS
|
|
149
|
+
all_messages = [message(each, long_text) for each in range(1, message_count + 1)]
|
|
150
|
+
all_kept = extractor.bounded_messages(all_messages)
|
|
151
|
+
kept_positions = [each.position for each in all_kept]
|
|
152
|
+
assert kept_positions[0] == 1
|
|
153
|
+
assert kept_positions[-1] == message_count
|
|
154
|
+
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
|
+
|
|
159
|
+
|
|
160
|
+
def test_bounded_messages_should_return_nothing_for_no_messages() -> None:
|
|
161
|
+
assert extractor.bounded_messages([]) == []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_rendered_extract_should_put_the_trim_rules_first_and_number_each_message() -> None:
|
|
165
|
+
rendered = extractor.rendered_extract([message(1, "fix login"), message(2, "use /srv")])
|
|
166
|
+
assert rendered.startswith(TRIM_INSTRUCTIONS)
|
|
167
|
+
assert "## 1 | t\nfix login\n" in rendered
|
|
168
|
+
assert "## 2 | t\nuse /srv\n" in rendered
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_rendered_extract_should_cut_a_long_message_and_say_how_much() -> None:
|
|
172
|
+
rendered = extractor.rendered_extract([message(1, "y" * (MAXIMUM_MESSAGE_CHARACTERS + 25))])
|
|
173
|
+
assert "[25 more characters left out]" in rendered
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_rendered_extract_should_name_how_many_messages_were_left_out() -> None:
|
|
177
|
+
long_text = "x" * (MAXIMUM_MESSAGE_CHARACTERS - 100)
|
|
178
|
+
message_count = 2 * MAXIMUM_EXTRACT_CHARACTERS // MAXIMUM_MESSAGE_CHARACTERS
|
|
179
|
+
all_messages = [message(each, long_text) for each in range(1, message_count + 1)]
|
|
180
|
+
left_out_count = message_count - len(extractor.bounded_messages(all_messages))
|
|
181
|
+
assert left_out_count > 0
|
|
182
|
+
assert f"[{left_out_count} earlier messages left out" in extractor.rendered_extract(
|
|
183
|
+
all_messages
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_rendered_extract_should_say_when_there_are_no_messages() -> None:
|
|
188
|
+
assert "No typed user messages" in extractor.rendered_extract([])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_output_path_for_should_name_the_file_after_the_session() -> None:
|
|
192
|
+
output_path = extractor.output_path_for(Path("/p/abc-123.jsonl"))
|
|
193
|
+
assert output_path.name == "compaction-user-messages-abc-123.md"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_main_should_print_only_the_path_of_the_file_it_writes(tmp_path: Path) -> None:
|
|
197
|
+
transcript_path = tmp_path / "session-xyz.jsonl"
|
|
198
|
+
transcript_path.write_text(user_line("ship the deploy fix") + "\n", encoding="utf-8")
|
|
199
|
+
completed = subprocess.run(
|
|
200
|
+
[sys.executable, str(EXTRACTOR_PATH), str(transcript_path)],
|
|
201
|
+
capture_output=True,
|
|
202
|
+
text=True,
|
|
203
|
+
check=True,
|
|
204
|
+
)
|
|
205
|
+
printed_path = Path(completed.stdout.strip())
|
|
206
|
+
assert printed_path == extractor.output_path_for(transcript_path)
|
|
207
|
+
assert "ship the deploy fix" not in completed.stdout
|
|
208
|
+
assert "## 1 | 2026-10-19T10:00:00Z\nship the deploy fix" in printed_path.read_text()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_main_should_exit_with_usage_when_the_transcript_is_missing(tmp_path: Path) -> None:
|
|
212
|
+
for all_arguments in ([], [str(tmp_path / "absent.jsonl")]):
|
|
213
|
+
completed = subprocess.run(
|
|
214
|
+
[sys.executable, str(EXTRACTOR_PATH), *all_arguments],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
assert completed.returncode == 2
|
|
220
|
+
assert completed.stdout == ""
|
package/package.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Install this package into a scratch home directory.
|
|
4
4
|
*
|
|
5
|
-
* The installer writes into `$HOME/.claude
|
|
6
|
-
* configuration.
|
|
5
|
+
* The installer writes into `$HOME/.claude`, rewrites the global git
|
|
6
|
+
* configuration, and edits `$CODEX_HOME/config.toml`. All three are redirected here so a run leaves the caller's own home
|
|
7
7
|
* untouched.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -44,6 +44,7 @@ export function scratchHomeEnvironment(scratchHome) {
|
|
|
44
44
|
HOME: scratchHome,
|
|
45
45
|
USERPROFILE: scratchHome,
|
|
46
46
|
GIT_CONFIG_GLOBAL: join(scratchHome, SANDBOX_GIT_CONFIG_NAME),
|
|
47
|
+
CODEX_HOME: join(scratchHome, '.codex'),
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import test from 'node:test';
|
|
5
6
|
|
|
@@ -30,6 +31,7 @@ test('a scratch home starts with an isolated global git configuration and is rem
|
|
|
30
31
|
assert.equal(environment.HOME, scratchHome);
|
|
31
32
|
assert.equal(environment.USERPROFILE, scratchHome);
|
|
32
33
|
assert.equal(environment.GIT_CONFIG_GLOBAL, gitConfigPath);
|
|
34
|
+
assert.equal(environment.CODEX_HOME, join(scratchHome, '.codex'));
|
|
33
35
|
} finally {
|
|
34
36
|
removeScratchHome(scratchHome);
|
|
35
37
|
}
|
|
@@ -47,3 +49,23 @@ test('the installer runs against a scratch home and leaves the caller home alone
|
|
|
47
49
|
removeScratchHome(scratchHome);
|
|
48
50
|
}
|
|
49
51
|
});
|
|
52
|
+
|
|
53
|
+
test('an install into a scratch home leaves the caller Codex home config alone', () => {
|
|
54
|
+
const callerCodexHome = mkdtempSync(join(tmpdir(), 'cde-caller-codex-'));
|
|
55
|
+
const callerConfigPath = join(callerCodexHome, 'config.toml');
|
|
56
|
+
const callerConfigBody = 'model = "caller"\n';
|
|
57
|
+
writeFileSync(callerConfigPath, callerConfigBody);
|
|
58
|
+
const priorCodexHome = process.env.CODEX_HOME;
|
|
59
|
+
process.env.CODEX_HOME = callerCodexHome;
|
|
60
|
+
const scratchHome = createScratchHome();
|
|
61
|
+
try {
|
|
62
|
+
const install = runInstaller(scratchHome, []);
|
|
63
|
+
assert.equal(install.status, 0, `${install.stdout}\n${install.stderr}`);
|
|
64
|
+
assert.equal(readFileSync(callerConfigPath, 'utf8'), callerConfigBody);
|
|
65
|
+
} finally {
|
|
66
|
+
if (priorCodexHome === undefined) delete process.env.CODEX_HOME;
|
|
67
|
+
else process.env.CODEX_HOME = priorCodexHome;
|
|
68
|
+
removeScratchHome(scratchHome);
|
|
69
|
+
rmSync(callerCodexHome, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|