claude-dev-env 8.73.2 → 8.74.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/cde.mjs CHANGED
@@ -45,6 +45,7 @@ export function createHelpText() {
45
45
  " cde followup brief Write the task an agent fixes them from",
46
46
  " cde followup count Report the backlog against the threshold",
47
47
  " cde followup clear Empty the ledger",
48
+ " cde followup dedupe Remove exact repeated ledger lines",
48
49
  "",
49
50
  "Python may also be selected with CDE_PYTHON.",
50
51
  ].join("\n");
package/bin/cde.test.mjs CHANGED
@@ -292,4 +292,5 @@ test("dispatches followup to the follow-up ledger command", async () => {
292
292
  test("help names the followup command", () => {
293
293
  assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
294
294
  assert.match(createHelpText(), /cde followup count/);
295
+ assert.match(createHelpText(), /cde followup dedupe/);
295
296
  });
@@ -263,10 +263,18 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
263
263
  }
264
264
  });
265
265
 
266
- test('package settings.json publishes the session agent, advisor model and auto mode defaults', () => {
266
+ test('package settings.json publishes the session agent, advisor model, auto-compact and auto mode defaults', () => {
267
267
  const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
268
268
  const settingsDefaults = settingsDefaultsFromPackageSettings(packageSettings);
269
- assert.deepEqual(Object.keys(settingsDefaults).sort(), ['advisorModel', 'agent', 'autoMode']);
269
+ assert.deepEqual(
270
+ Object.keys(settingsDefaults).sort(),
271
+ ['advisorModel', 'agent', 'autoCompactWindow', 'autoMode', 'modelSettings'],
272
+ );
273
+ assert.equal(settingsDefaults.autoCompactWindow, 350000);
274
+ assert.deepEqual(settingsDefaults.modelSettings, {
275
+ 'claude-haiku-5-5': { autoCompactWindow: 100000 },
276
+ 'claude-haiku-4-5': { autoCompactWindow: 100000 },
277
+ });
270
278
  assert.equal(settingsDefaults.agent, 'dev-env-session');
271
279
  assert.ok(
272
280
  existsSync(join(PACKAGE_ROOT, '.agents', 'agents', `${settingsDefaults.agent}.md`)),
@@ -4,9 +4,35 @@
4
4
  * Every top-level key in the package settings.json other than the managed
5
5
  * lists (`permissions`) is a default. Install adds a default only when the
6
6
  * user settings lack that key, so a value the user already set stays as is.
7
+ *
8
+ * A per-model key (`modelSettings`) merges one level deeper: install adds each
9
+ * model entry, and each field inside an entry, that the user settings lack.
7
10
  */
8
11
 
9
12
  export const MANAGED_LIST_SETTING_KEYS = Object.freeze(['permissions']);
13
+ export const PER_MODEL_SETTING_KEYS = Object.freeze(['modelSettings']);
14
+
15
+ function isPlainObject(candidate) {
16
+ return Boolean(candidate) && typeof candidate === 'object' && !Array.isArray(candidate);
17
+ }
18
+
19
+ function mergeMissingPerModelFields(targetPerModel, defaultPerModel, settingKey) {
20
+ const addedKeys = [];
21
+ for (const [modelName, defaultFields] of Object.entries(defaultPerModel)) {
22
+ if (!Object.hasOwn(targetPerModel, modelName)) {
23
+ targetPerModel[modelName] = { ...defaultFields };
24
+ addedKeys.push(`${settingKey}.${modelName}`);
25
+ continue;
26
+ }
27
+ if (!isPlainObject(targetPerModel[modelName])) continue;
28
+ for (const [fieldName, fieldDefault] of Object.entries(defaultFields)) {
29
+ if (Object.hasOwn(targetPerModel[modelName], fieldName)) continue;
30
+ targetPerModel[modelName][fieldName] = fieldDefault;
31
+ addedKeys.push(`${settingKey}.${modelName}.${fieldName}`);
32
+ }
33
+ }
34
+ return addedKeys;
35
+ }
10
36
 
11
37
  /**
12
38
  * Read the setting defaults from a package settings source object.
@@ -35,8 +61,15 @@ export function settingsDefaultsFromPackageSettings(packageSettings) {
35
61
  export function mergeMissingSettingsDefaults(targetSettings, settingsDefaults) {
36
62
  const addedKeys = [];
37
63
  for (const [settingKey, defaultValue] of Object.entries(settingsDefaults)) {
64
+ const isPerModelMerge = PER_MODEL_SETTING_KEYS.includes(settingKey)
65
+ && isPlainObject(targetSettings[settingKey])
66
+ && isPlainObject(defaultValue);
67
+ if (isPerModelMerge) {
68
+ addedKeys.push(...mergeMissingPerModelFields(targetSettings[settingKey], defaultValue, settingKey));
69
+ continue;
70
+ }
38
71
  if (Object.hasOwn(targetSettings, settingKey)) continue;
39
- targetSettings[settingKey] = defaultValue;
72
+ targetSettings[settingKey] = structuredClone(defaultValue);
40
73
  addedKeys.push(settingKey);
41
74
  }
42
75
  return { addedKeys };
@@ -41,3 +41,56 @@ test('mergeMissingSettingsDefaults adds absent keys and keeps present values', (
41
41
  theme: 'dark',
42
42
  });
43
43
  });
44
+
45
+ test('mergeMissingSettingsDefaults should add a missing model entry and a missing field inside a present one', () => {
46
+ const targetSettings = {
47
+ modelSettings: {
48
+ 'claude-opus-5-5': { effortLevel: 'medium' },
49
+ 'claude-haiku-4-5': { effortLevel: 'low' },
50
+ },
51
+ };
52
+
53
+ assert.deepEqual(
54
+ mergeMissingSettingsDefaults(targetSettings, {
55
+ modelSettings: {
56
+ 'claude-haiku-5-5': { autoCompactWindow: 100000 },
57
+ 'claude-haiku-4-5': { autoCompactWindow: 100000 },
58
+ },
59
+ }),
60
+ {
61
+ addedKeys: [
62
+ 'modelSettings.claude-haiku-5-5',
63
+ 'modelSettings.claude-haiku-4-5.autoCompactWindow',
64
+ ],
65
+ },
66
+ );
67
+ assert.deepEqual(targetSettings.modelSettings, {
68
+ 'claude-opus-5-5': { effortLevel: 'medium' },
69
+ 'claude-haiku-4-5': { effortLevel: 'low', autoCompactWindow: 100000 },
70
+ 'claude-haiku-5-5': { autoCompactWindow: 100000 },
71
+ });
72
+ });
73
+
74
+ test('mergeMissingSettingsDefaults should give each target its own copy of an added default', () => {
75
+ const settingsDefaults = { modelSettings: { 'claude-haiku-5-5': { autoCompactWindow: 100000 } } };
76
+ const firstTarget = {};
77
+ const secondTarget = {};
78
+
79
+ mergeMissingSettingsDefaults(firstTarget, settingsDefaults);
80
+ firstTarget.modelSettings['claude-haiku-5-5'].autoCompactWindow = 200000;
81
+ mergeMissingSettingsDefaults(secondTarget, settingsDefaults);
82
+
83
+ assert.deepEqual(secondTarget.modelSettings, { 'claude-haiku-5-5': { autoCompactWindow: 100000 } });
84
+ });
85
+
86
+ test('mergeMissingSettingsDefaults should keep a per-model value the user already set', () => {
87
+ const targetSettings = { modelSettings: { 'claude-haiku-5-5': { autoCompactWindow: 150000 } } };
88
+
89
+ assert.deepEqual(
90
+ mergeMissingSettingsDefaults(targetSettings, {
91
+ modelSettings: { 'claude-haiku-5-5': { autoCompactWindow: 100000 } },
92
+ }),
93
+ { addedKeys: [] },
94
+ );
95
+ assert.deepEqual(targetSettings.modelSettings, { 'claude-haiku-5-5': { autoCompactWindow: 150000 } });
96
+ });
@@ -38,9 +38,12 @@ A message with no catalog entry resolves to `<rule>/unclassified`, which a parti
38
38
  | `cde followup brief` | Writes the task an agent fixes them from |
39
39
  | `cde followup clear` | Empties the ledger |
40
40
  | `cde followup count` | Reports the backlog against the threshold |
41
+ | `cde followup dedupe` | Removes exact repeated lines and keeps the first of each |
41
42
 
42
43
  `count` exits non-zero once the backlog passes `FOLLOWUP_BACKLOG_THRESHOLD` in `scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job escalates. The number is the repository's setting.
43
44
 
45
+ `.gitattributes` marks the ledger `merge=union`, so a local `git merge` keeps both sides' appended lines without a conflict. GitHub's server-side merge ignores the attribute. Every ledger write drops exact repeated lines, and the `followup-ledger-duplicates` repository check fails a tree that still holds one.
46
+
44
47
  The `/fix-followups` command reads the brief, fixes each rule group, opens a pull request, and clears the ledger.
45
48
 
46
49
  ## Worked example
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: deny a Haiku agent spawned inside this session.
3
+
4
+ Registered on ``Agent|Task`` and ``mcp__hearthbot__start_thread_session``. An
5
+ in-session spawn shares this session's settings, plugins, MCP servers, and
6
+ CLAUDE.md files, so a Haiku agent started that way cannot run on the slim
7
+ profile. The deny reason gives the headless command that loads the profile.
8
+
9
+ ::
10
+
11
+ model "haiku" -> deny with the headless command
12
+ model "claude-haiku-5-5" -> deny with the headless command
13
+ model "opus", "sonnet", or omitted -> no output; the call runs unchanged
14
+
15
+ A spawn that names no model passes, even when the subagent's definition or
16
+ ``CLAUDE_CODE_SUBAGENT_MODEL`` resolves it to Haiku.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
26
+ if hooks_root_directory not in sys.path:
27
+ sys.path.insert(0, hooks_root_directory)
28
+
29
+ from hooks_constants.haiku_spawn_slim_gate_constants import (
30
+ DENY_DECISION,
31
+ HAIKU_MODEL_FAMILY,
32
+ HOOK_EVENT_NAME,
33
+ MODEL_INPUT_KEY,
34
+ PERMISSION_DECISION_REASON_KEY,
35
+ SLIM_PROFILE_DENY_REASON,
36
+ TOOL_INPUT_KEY,
37
+ )
38
+ from hooks_constants.hook_specific_output_keys import (
39
+ HOOK_EVENT_NAME_KEY,
40
+ HOOK_SPECIFIC_OUTPUT_KEY,
41
+ PERMISSION_DECISION_KEY,
42
+ )
43
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
44
+
45
+
46
+ def is_haiku_model(model: object) -> bool:
47
+ """Return True when the spawn's model field names the Haiku family.
48
+
49
+ ::
50
+
51
+ "haiku" -> True "claude-haiku-4-5-20251001" -> True
52
+ "opus" -> False None -> False
53
+
54
+ Args:
55
+ model: The ``model`` field of the spawn input.
56
+ """
57
+ return isinstance(model, str) and HAIKU_MODEL_FAMILY in model.lower()
58
+
59
+
60
+ def decide_hook_output(tool_input: object) -> dict[str, object] | None:
61
+ """Choose the hook's output for one spawn.
62
+
63
+ Args:
64
+ tool_input: The ``tool_input`` of the PreToolUse payload.
65
+
66
+ Returns:
67
+ None to pass the call through unchanged, else the deny output.
68
+ """
69
+ if not isinstance(tool_input, dict) or not is_haiku_model(tool_input.get(MODEL_INPUT_KEY)):
70
+ return None
71
+ return {
72
+ HOOK_SPECIFIC_OUTPUT_KEY: {
73
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
74
+ PERMISSION_DECISION_KEY: DENY_DECISION,
75
+ PERMISSION_DECISION_REASON_KEY: SLIM_PROFILE_DENY_REASON,
76
+ }
77
+ }
78
+
79
+
80
+ def main() -> int:
81
+ """Read the PreToolUse payload and print the decision.
82
+
83
+ Returns:
84
+ 0 in every case.
85
+ """
86
+ hook_payload = read_hook_input_dictionary_from_stdin()
87
+ tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
88
+ hook_output = decide_hook_output(tool_input)
89
+ if hook_output is not None:
90
+ sys.stdout.write(json.dumps(hook_output))
91
+ sys.stdout.flush()
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import haiku_spawn_slim_gate
9
+ from hooks_constants.haiku_spawn_slim_gate_constants import SLIM_PROFILE_DENY_REASON
10
+
11
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "haiku_spawn_slim_gate.py"
12
+
13
+
14
+ def _run_hook(payload: dict[str, object]) -> str:
15
+ completed = subprocess.run(
16
+ [sys.executable, str(HOOK_SCRIPT)],
17
+ input=json.dumps(payload),
18
+ capture_output=True,
19
+ text=True,
20
+ check=True,
21
+ )
22
+ return completed.stdout
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ ("tool_name", "model"),
27
+ [
28
+ ("Agent", "haiku"),
29
+ ("Task", "Haiku"),
30
+ ("Agent", "claude-haiku-5-5"),
31
+ ("Agent", "claude-haiku-4-5-20251001"),
32
+ ("mcp__hearthbot__start_thread_session", "claude-haiku-5-5"),
33
+ ],
34
+ )
35
+ def test_should_deny_a_haiku_spawn_with_the_headless_command(tool_name: str, model: str) -> None:
36
+ payload = {"tool_name": tool_name, "tool_input": {"prompt": "Review.", "model": model}}
37
+ hook_output = json.loads(_run_hook(payload))["hookSpecificOutput"]
38
+ assert hook_output["hookEventName"] == "PreToolUse"
39
+ assert hook_output["permissionDecision"] == "deny"
40
+ assert hook_output["permissionDecisionReason"] == SLIM_PROFILE_DENY_REASON
41
+
42
+
43
+ @pytest.mark.parametrize("model", ["opus", "sonnet", "claude-opus-5-5", "fable", None, "", 7])
44
+ def test_should_pass_a_spawn_that_names_no_haiku_model(model: object) -> None:
45
+ tool_input = {"prompt": "Review."}
46
+ if model is not None:
47
+ tool_input["model"] = model
48
+ assert _run_hook({"tool_name": "Agent", "tool_input": tool_input}) == ""
49
+
50
+
51
+ def test_should_pass_a_payload_without_tool_input() -> None:
52
+ assert haiku_spawn_slim_gate.decide_hook_output(None) is None
53
+
54
+
55
+ def test_should_name_every_slim_profile_flag_in_the_deny_reason() -> None:
56
+ for each_flag in (
57
+ "account_broker.py",
58
+ "--model haiku",
59
+ "--setting-sources local",
60
+ "--strict-mcp-config",
61
+ "--autocompact 100k",
62
+ "CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
63
+ "POSIX shell",
64
+ ):
65
+ assert each_flag in SLIM_PROFILE_DENY_REASON
@@ -1,6 +1,6 @@
1
1
  # Spawn routing
2
2
 
3
- This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, and selects an allowed subagent model.
3
+ This family reminds the agent to gather context before a spawn, tells it that it owns the spawned work, adjusts thread requests when usage exceeds pace, selects an allowed subagent model, and sends a Haiku spawn to a headless slim-profile run.
4
4
 
5
5
  ## Checks
6
6
 
@@ -8,6 +8,7 @@ This family reminds the agent to gather context before a spawn, tells it that it
8
8
  - `routing/spawn_oversight_hook.py` adds the orchestrator oversight directive to every spawn: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. The spawn still runs.
9
9
  - `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
10
10
  - `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
11
+ - `blocking/haiku_spawn_slim_gate.py` denies an Agent, Task, or thread spawn whose `model` names Haiku. The deny reason gives the headless `claude -p` command that loads the slim profile through the account broker.
11
12
 
12
13
  ## When it fires
13
14
 
@@ -15,6 +16,7 @@ This family reminds the agent to gather context before a spawn, tells it that it
15
16
  - `routing/spawn_oversight_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, `mcp__hearthbot__start_thread_session`, and `mcp__hearthbot__start_rc_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
16
17
  - `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
17
18
  - `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
19
+ - `blocking/haiku_spawn_slim_gate.py` runs on `PreToolUse` with matchers `Agent|Task` and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`.
18
20
 
19
21
  ## Proving it
20
22
 
@@ -25,6 +27,7 @@ Preconditions:
25
27
  - **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
26
28
  - **Spawn oversight.** Input is a spawn from each registered tool, a subagent's own spawn, and a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py -q`. The adjacent test observes the directive for every spawn and no output for the other calls.
27
29
  - **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
30
+ - **Haiku slim gate.** Input is a spawn with a Haiku alias or model ID, and spawns on other models or with no model. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_haiku_spawn_slim_gate.py -q`. The adjacent test observes a deny with the headless command for each Haiku spawn and no output for the rest.
28
31
  - **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
29
32
 
30
33
  ## Gotchas
@@ -32,4 +35,5 @@ Preconditions:
32
35
  - `routing/spawn_readiness_hook.py` also runs after the pace hook on thread spawns. A reshaped request still gets the readiness check.
33
36
  - The oversight hook fires inside subagents too, so each level of the tree oversees the level below it. It emits `additionalContext` only and never blocks.
34
37
  - The readiness hook emits `additionalContext` only, so it never blocks. It passes subagent calls, read-only helper types, a non-object tool input, and unreadable transcripts.
38
+ - The Haiku slim gate reads only the spawn's `model` field. A spawn that names no model passes, even when its agent definition or `CLAUDE_CODE_SUBAGENT_MODEL` resolves it to Haiku.
35
39
  - The model router allows an advisor flag to bypass the Luna restriction. Its ordinary path denies unresolved or unsupported models.
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import sys
17
+ from collections.abc import Sequence
17
18
  from pathlib import Path
18
19
  from typing import NamedTuple
19
20
 
@@ -74,6 +75,52 @@ class FollowupFinding(NamedTuple):
74
75
  return (self.check_id or self.rule_id, self.file_path, self.message)
75
76
 
76
77
 
78
+ def deduplicated_lines(all_lines: Sequence[str]) -> list[str]:
79
+ """Return the lines with every exact repeat removed.
80
+
81
+ The first occurrence of each line keeps its place, so the ledger keeps its
82
+ record order after a ``merge=union`` merge writes both sides' lines.
83
+
84
+ Args:
85
+ all_lines: The ledger lines, in file order.
86
+
87
+ Returns:
88
+ The lines in file order, each exact line once.
89
+ """
90
+ return list(dict.fromkeys(all_lines))
91
+
92
+
93
+ def deduplicate_ledger(repository_root: Path) -> int:
94
+ """Rewrite the repository's ledger without exact repeated lines.
95
+
96
+ An absent or unreadable ledger, and a ledger that cannot be rewritten, are
97
+ left as they are, so a ledger failure never becomes a gate failure.
98
+
99
+ Args:
100
+ repository_root: The repository whose ledger to clean.
101
+
102
+ Returns:
103
+ How many lines the rewrite removed.
104
+ """
105
+ ledger_path = followup_ledger_path(repository_root)
106
+ try:
107
+ all_ledger_lines = ledger_path.read_text(encoding=LEDGER_ENCODING).splitlines()
108
+ except (OSError, UnicodeError):
109
+ return 0
110
+ all_unique_lines = deduplicated_lines(all_ledger_lines)
111
+ removed_line_count = len(all_ledger_lines) - len(all_unique_lines)
112
+ if not removed_line_count:
113
+ return 0
114
+ try:
115
+ ledger_path.write_text(
116
+ "".join(each_line + "\n" for each_line in all_unique_lines),
117
+ encoding=LEDGER_ENCODING,
118
+ )
119
+ except OSError:
120
+ return 0
121
+ return removed_line_count
122
+
123
+
77
124
  def followup_ledger_path(repository_root: Path) -> Path:
78
125
  """Return the ledger path for one repository.
79
126
 
@@ -89,8 +136,10 @@ def followup_ledger_path(repository_root: Path) -> Path:
89
136
  def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
90
137
  """Append one finding to the repository's ledger, once.
91
138
 
92
- A finding already present in the ledger is left alone, so a gate that runs
93
- on every commit records a standing smell a single time. The ledger
139
+ The ledger first loses every exact repeated line, so lines a
140
+ ``merge=union`` merge wrote twice leave on the next write. A finding
141
+ already present in the ledger is left alone, so a gate that runs on every
142
+ commit records a standing smell a single time. The ledger
94
143
  directory carries a ``.gitignore`` matching every file in it, so the
95
144
  ledger stays out of ``git status`` in any repository. Every filesystem
96
145
  error is swallowed, so a ledger failure leaves the caller's gate decision
@@ -100,6 +149,7 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
100
149
  repository_root: The repository whose ledger receives the finding.
101
150
  finding: The non-breaking finding to record.
102
151
  """
152
+ deduplicate_ledger(repository_root)
103
153
  all_recorded_keys = {
104
154
  each_finding.tracking_key()
105
155
  for each_finding in all_recorded_findings(repository_root)
package/hooks/hooks.json CHANGED
@@ -10,6 +10,11 @@
10
10
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
11
11
  "timeout": 10
12
12
  },
13
+ {
14
+ "type": "command",
15
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
16
+ "timeout": 10
17
+ },
13
18
  {
14
19
  "type": "command",
15
20
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
@@ -180,6 +185,11 @@
180
185
  {
181
186
  "matcher": "mcp__hearthbot__start_thread_session",
182
187
  "hooks": [
188
+ {
189
+ "type": "command",
190
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
191
+ "timeout": 10
192
+ },
183
193
  {
184
194
  "type": "command",
185
195
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/thread_spawn_pace_hook.py",
@@ -0,0 +1,29 @@
1
+ """Constants for the Haiku spawn slim-profile PreToolUse gate.
2
+
3
+ Groups: the model family the gate denies, the hook event name, the hook input and output keys, and
4
+ the deny reason that gives the headless slim-profile command.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ HAIKU_MODEL_FAMILY = "haiku"
10
+ HOOK_EVENT_NAME = "PreToolUse"
11
+ TOOL_INPUT_KEY = "tool_input"
12
+ MODEL_INPUT_KEY = "model"
13
+ DENY_DECISION = "deny"
14
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
15
+ SLIM_PROFILE_DENY_REASON = (
16
+ "BLOCKED: A Haiku agent started from this session runs with this session's settings, plugins, "
17
+ "MCP servers, and CLAUDE.md files. An Agent, Task, or thread spawn has no field for setting sources, "
18
+ "a plugin directory, or an MCP config, so it cannot load the slim profile. "
19
+ "Start Haiku as a headless run with the slim profile. Set CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and "
20
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 in the environment, then run this from Bash or another POSIX shell, "
21
+ "because PowerShell strips the double quotes inside the single-quoted JSON: "
22
+ 'python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <report.json> -- '
23
+ "claude -p --model haiku --setting-sources local "
24
+ '--settings \'{"enabledPlugins": {}, "autoMemoryEnabled": false, "autoCompactWindow": 100000}\' '
25
+ "--strict-mcp-config --mcp-config '{\"mcpServers\": {}}' --autocompact 100k. "
26
+ "Send the task on standard input. In a cloud session, run the same claude -p command without the broker. "
27
+ "Where the efficient-review skill is installed, /efficient-review headless or /efficient-review auto builds "
28
+ "this profile. To keep the agent in this session, spawn it on opus or sonnet."
29
+ )
@@ -12,6 +12,8 @@ if _hooks_directory not in sys.path:
12
12
  from followup_ledger import (
13
13
  FollowupFinding,
14
14
  all_recorded_findings,
15
+ deduplicate_ledger,
16
+ deduplicated_lines,
15
17
  followup_ledger_path,
16
18
  head_commit,
17
19
  record_followup_finding,
@@ -187,3 +189,59 @@ def test_head_commit_reads_a_detached_revision(tmp_path: Path) -> None:
187
189
 
188
190
  def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
189
191
  assert head_commit(tmp_path) == ""
192
+
193
+
194
+ def test_deduplicated_lines_keeps_the_first_of_each_exact_line_in_order() -> None:
195
+ assert deduplicated_lines(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
196
+
197
+
198
+ def test_record_followup_finding_removes_lines_a_union_merge_repeated(
199
+ tmp_path: Path,
200
+ ) -> None:
201
+ ledger_path = followup_ledger_path(tmp_path)
202
+ ledger_path.parent.mkdir(parents=True)
203
+ first_line = '{"rule_id": "r1", "file_path": "a.py", "message": "m1"}'
204
+ second_line = '{"rule_id": "r2", "file_path": "b.py", "message": "m2"}'
205
+ ledger_path.write_text(
206
+ f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
207
+ )
208
+
209
+ record_followup_finding(tmp_path, FollowupFinding("r3", "c.py", "m3", "r3"))
210
+
211
+ all_ledger_lines = ledger_path.read_text(encoding="utf-8").splitlines()
212
+ assert all_ledger_lines[:2] == [first_line, second_line]
213
+ assert [json.loads(each_line)["rule_id"] for each_line in all_ledger_lines] == [
214
+ "r1",
215
+ "r2",
216
+ "r3",
217
+ ]
218
+
219
+
220
+ def test_record_followup_finding_removes_a_repeat_of_an_already_recorded_finding(
221
+ tmp_path: Path,
222
+ ) -> None:
223
+ finding = FollowupFinding("r1", "a.py", "m1", "r1")
224
+ record_followup_finding(tmp_path, finding)
225
+ ledger_path = followup_ledger_path(tmp_path)
226
+ recorded_text = ledger_path.read_text(encoding="utf-8")
227
+ ledger_path.write_text(recorded_text * 2, encoding="utf-8")
228
+
229
+ record_followup_finding(tmp_path, finding)
230
+
231
+ assert ledger_path.read_text(encoding="utf-8") == recorded_text
232
+
233
+
234
+ def test_deduplicate_ledger_reports_how_many_lines_it_removed(tmp_path: Path) -> None:
235
+ ledger_path = followup_ledger_path(tmp_path)
236
+ ledger_path.parent.mkdir(parents=True)
237
+ ledger_path.write_text("x\ny\nx\nx\n", encoding="utf-8")
238
+
239
+ removed_count = deduplicate_ledger(tmp_path)
240
+
241
+ assert removed_count == 2
242
+ assert ledger_path.read_text(encoding="utf-8") == "x\ny\n"
243
+
244
+
245
+ def test_deduplicate_ledger_leaves_an_absent_ledger_absent(tmp_path: Path) -> None:
246
+ assert deduplicate_ledger(tmp_path) == 0
247
+ assert not followup_ledger_path(tmp_path).exists()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.73.2",
3
+ "version": "8.74.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,12 +31,14 @@ INGEST_COMMAND_NAME: str = "ingest"
31
31
  BRIEF_COMMAND_NAME: str = "brief"
32
32
  CLEAR_COMMAND_NAME: str = "clear"
33
33
  COUNT_COMMAND_NAME: str = "count"
34
+ DEDUPE_COMMAND_NAME: str = "dedupe"
34
35
  ALL_COMMAND_NAMES: tuple[str, ...] = (
35
36
  LIST_COMMAND_NAME,
36
37
  INGEST_COMMAND_NAME,
37
38
  BRIEF_COMMAND_NAME,
38
39
  CLEAR_COMMAND_NAME,
39
40
  COUNT_COMMAND_NAME,
41
+ DEDUPE_COMMAND_NAME,
40
42
  )
41
43
 
42
44
  SUCCESS_EXIT_CODE: int = 0
@@ -61,8 +63,9 @@ ABSENT_LOCATION_PATH: str = ""
61
63
  LINE_SEPARATOR: str = "\n"
62
64
  EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
63
65
  UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
66
+ DEDUPE_RESULT_TEMPLATE: str = "removed {removed_count} duplicate ledger lines"
64
67
  USAGE_TEXT: str = (
65
- "Usage: followup_cli.py <list|ingest|brief|clear|count> "
68
+ "Usage: followup_cli.py <list|ingest|brief|clear|count|dedupe> "
66
69
  "[--repository-root PATH]\n"
67
70
  " list Name every recorded follow-up\n"
68
71
  " ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
@@ -12,7 +12,8 @@ work. This command reads those records back.
12
12
  ``list`` names what is outstanding, ``ingest`` adds the diagnostics from a
13
13
  policy-lint JSON report, ``brief`` writes the task an agent works from, and
14
14
  ``clear`` empties the ledger once the follow-up pull request carries the
15
- fixes.
15
+ fixes, and ``dedupe`` removes exact repeated lines a ``merge=union`` merge
16
+ left behind.
16
17
  """
17
18
 
18
19
  from __future__ import annotations
@@ -30,6 +31,8 @@ from dev_env_scripts_constants.followup_constants import (
30
31
  BACKLOG_EXCEEDED_TEMPLATE,
31
32
  BACKLOG_WITHIN_TEMPLATE,
32
33
  COUNT_COMMAND_NAME,
34
+ DEDUPE_COMMAND_NAME,
35
+ DEDUPE_RESULT_TEMPLATE,
33
36
  DIAGNOSTIC_CHECK_ID_KEY,
34
37
  FOLLOWUP_BACKLOG_THRESHOLD,
35
38
  BRIEF_COMMAND_NAME,
@@ -59,6 +62,7 @@ if _hooks_directory not in sys.path:
59
62
  from followup_ledger import (
60
63
  FollowupFinding,
61
64
  all_recorded_findings,
65
+ deduplicate_ledger,
62
66
  followup_ledger_path,
63
67
  head_commit,
64
68
  record_followup_finding,
@@ -310,6 +314,24 @@ def _run_clear(repository_root: Path) -> int:
310
314
  return SUCCESS_EXIT_CODE
311
315
 
312
316
 
317
+ def _run_dedupe(repository_root: Path, stdout: TextIO) -> int:
318
+ """Remove exact repeated lines from the repository's ledger.
319
+
320
+ Args:
321
+ repository_root: The repository whose ledger to clean.
322
+ stdout: The stream the removed-line count goes to.
323
+
324
+ Returns:
325
+ SUCCESS_EXIT_CODE.
326
+ """
327
+ removed_line_count = deduplicate_ledger(repository_root)
328
+ stdout.write(
329
+ DEDUPE_RESULT_TEMPLATE.format(removed_count=removed_line_count)
330
+ + LINE_SEPARATOR
331
+ )
332
+ return SUCCESS_EXIT_CODE
333
+
334
+
313
335
  def main(all_arguments: list[str], stdout: TextIO = sys.stdout) -> int:
314
336
  """Run one follow-up command.
315
337
 
@@ -336,6 +358,8 @@ def main(all_arguments: list[str], stdout: TextIO = sys.stdout) -> int:
336
358
  return _run_brief(repository_root, stdout)
337
359
  if parsed_arguments.command == COUNT_COMMAND_NAME:
338
360
  return _run_count(repository_root, stdout)
361
+ if parsed_arguments.command == DEDUPE_COMMAND_NAME:
362
+ return _run_dedupe(repository_root, stdout)
339
363
  return _run_clear(repository_root)
340
364
 
341
365
 
@@ -73,6 +73,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
73
73
  "blocking/session_title_stop_gate.py",
74
74
  "blocking/image_read_size_gate.py",
75
75
  "blocking/artifact_dark_mode_gate.py",
76
+ "blocking/haiku_spawn_slim_gate.py",
76
77
  )
77
78
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
78
79
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -9,6 +9,7 @@ from policy_lint.config.constants import (
9
9
 
10
10
  CHECK_ID_CLAUDE_MD_ORPHANS = "claude-md-orphans"
11
11
  CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
12
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES = "followup-ledger-duplicates"
12
13
  CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
13
14
  CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
14
15
  CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
@@ -18,6 +19,7 @@ PRIVATE_TERMS_SKIPPED_FILE_NAME = "CHANGELOG.md"
18
19
  ALL_CHECK_IDS = (
19
20
  CHECK_ID_CLAUDE_MD_ORPHANS,
20
21
  CHECK_ID_ENV_VAR_DOCUMENTATION,
22
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
21
23
  CHECK_ID_PACKAGE_INVENTORY,
22
24
  CHECK_ID_PYTEST_TESTPATHS,
23
25
  CHECK_ID_TRACKED_PERSONAL_DATA,
@@ -29,6 +31,7 @@ SEVERITY_SMELL = "smell"
29
31
  SEVERITY_BY_CHECK_ID: dict[str, str] = {
30
32
  CHECK_ID_CLAUDE_MD_ORPHANS: SEVERITY_BREAKING,
31
33
  CHECK_ID_ENV_VAR_DOCUMENTATION: SEVERITY_BREAKING,
34
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES: SEVERITY_BREAKING,
32
35
  CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
33
36
  CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
34
37
  CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
@@ -49,6 +52,9 @@ EMPTY_REPORT_TEXT = ""
49
52
  TRACKED_MATCH_MESSAGE_TEMPLATE = "[{category}] {preview}"
50
53
  CLAUDE_MD_MISSING_FILE_MESSAGE_TEMPLATE = "references missing file {filename}"
51
54
  PACKAGE_INVENTORY_MESSAGE_TEMPLATE = "production file is absent from package inventory"
55
+ FOLLOWUP_LEDGER_DUPLICATE_MESSAGE_TEMPLATE = (
56
+ "{repeated_line_count} lines repeat an earlier line; run `cde followup dedupe`"
57
+ )
52
58
  PYTEST_TESTPATH_MESSAGE_TEMPLATE = (
53
59
  "test file is outside the package testpaths allowlist"
54
60
  )
@@ -0,0 +1,55 @@
1
+ """Committed follow-up ledger check for exact repeated lines.
2
+
3
+ GitHub's server-side merge ignores ``merge=union``, so repeated lines arrive
4
+ only through a local union merge. This check stops them at the merge queue.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+
13
+ from policy_lint.config import constants as policy_constants
14
+
15
+ from repository_checks.config import constants as repository_constants
16
+ from repository_checks.hook_modules import load_hooks_module
17
+ from repository_checks.models import RepositoryFinding
18
+
19
+
20
+ def collect_followup_ledger_duplicate_findings(
21
+ repository_root: Path, all_tracked_paths: Sequence[str]
22
+ ) -> list[RepositoryFinding]:
23
+ """Return a finding when the tracked ledger repeats a line.
24
+
25
+ Args:
26
+ repository_root: Git repository root.
27
+ all_tracked_paths: Repository-relative tracked paths.
28
+
29
+ Returns:
30
+ One finding naming the repeated-line count, or no findings.
31
+ """
32
+ ledger = load_hooks_module(repository_constants.FOLLOWUP_LEDGER_MODULE_NAME)
33
+ ledger_path = ledger.followup_ledger_path(repository_root)
34
+ relative_path = ledger_path.relative_to(repository_root).as_posix()
35
+ if relative_path not in all_tracked_paths:
36
+ return []
37
+ repeated_line_count = _repeated_line_count(ledger, ledger_path)
38
+ if not repeated_line_count:
39
+ return []
40
+ return [
41
+ RepositoryFinding(
42
+ repository_constants.CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
43
+ relative_path,
44
+ repository_constants.FOLLOWUP_LEDGER_DUPLICATE_MESSAGE_TEMPLATE.format(
45
+ repeated_line_count=repeated_line_count
46
+ ),
47
+ )
48
+ ]
49
+
50
+
51
+ def _repeated_line_count(ledger: ModuleType, ledger_path: Path) -> int:
52
+ all_ledger_lines = ledger_path.read_text(
53
+ encoding=policy_constants.UTF8_ENCODING
54
+ ).splitlines()
55
+ return len(all_ledger_lines) - len(ledger.deduplicated_lines(all_ledger_lines))
@@ -11,6 +11,9 @@ from repository_checks.config import constants as repository_constants
11
11
  from repository_checks.env_var_documentation import (
12
12
  collect_env_var_documentation_findings,
13
13
  )
14
+ from repository_checks.followup_ledger_duplicates import (
15
+ collect_followup_ledger_duplicate_findings,
16
+ )
14
17
  from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
18
  from repository_checks.package_inventory import collect_package_inventory_findings
16
19
  from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
@@ -81,6 +84,10 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
81
84
  repository_constants.CHECK_ID_ENV_VAR_DOCUMENTATION,
82
85
  collect_env_var_documentation_findings,
83
86
  ),
87
+ (
88
+ repository_constants.CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
89
+ collect_followup_ledger_duplicate_findings,
90
+ ),
84
91
  (
85
92
  repository_constants.CHECK_ID_PACKAGE_INVENTORY,
86
93
  collect_package_inventory_findings,
@@ -10,12 +10,14 @@ from dev_env_scripts_constants.followup_constants import (
10
10
  BACKLOG_EXCEEDED_EXIT_CODE,
11
11
  BACKLOG_EXCEEDED_TEMPLATE,
12
12
  BACKLOG_WITHIN_TEMPLATE,
13
+ DEDUPE_RESULT_TEMPLATE,
13
14
  FOLLOWUP_BACKLOG_THRESHOLD,
14
15
  )
15
16
  from followup_cli import main
16
17
  from followup_ledger import (
17
18
  FollowupFinding,
18
19
  all_recorded_findings,
20
+ followup_ledger_path,
19
21
  record_followup_finding,
20
22
  )
21
23
 
@@ -303,3 +305,19 @@ def test_list_names_the_check_behind_each_finding(tmp_path: Path) -> None:
303
305
 
304
306
  assert "code-rules/constant-outside-config" in output_text
305
307
  assert "f00dcafe" in output_text
308
+
309
+
310
+ def test_dedupe_removes_repeated_ledger_lines_and_keeps_the_order(
311
+ tmp_path: Path,
312
+ ) -> None:
313
+ ledger_path = followup_ledger_path(tmp_path)
314
+ ledger_path.parent.mkdir(parents=True)
315
+ ledger_path.write_text("first\nsecond\nfirst\n", encoding="utf-8")
316
+
317
+ exit_code, output_text = run_command(
318
+ ["dedupe", "--repository-root", str(tmp_path)]
319
+ )
320
+
321
+ assert exit_code == 0
322
+ assert output_text == DEDUPE_RESULT_TEMPLATE.format(removed_count=1) + "\n"
323
+ assert ledger_path.read_text(encoding="utf-8") == "first\nsecond\n"
@@ -0,0 +1,58 @@
1
+ """Behavior tests for the committed follow-up ledger duplicate check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _TESTS_DIRECTORY = Path(__file__).resolve().parent
9
+ if str(_TESTS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(_TESTS_DIRECTORY))
11
+
12
+ from repository_checks.config.constants import (
13
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
14
+ FINDINGS_EXIT_CODE,
15
+ SUCCESS_EXIT_CODE,
16
+ )
17
+ from repository_policy_test_support import (
18
+ commit_tracked_files,
19
+ initialize_repository,
20
+ run_policy,
21
+ write_text,
22
+ )
23
+
24
+ _LEDGER_RELATIVE_PATH = ".claude/followups/smells.jsonl"
25
+
26
+
27
+ def test_should_flag_a_committed_ledger_with_a_repeated_line(tmp_path: Path) -> None:
28
+ repository_root = tmp_path / "repo"
29
+ initialize_repository(repository_root)
30
+ write_text(
31
+ repository_root / _LEDGER_RELATIVE_PATH,
32
+ '{"rule_id": "a"}\n{"rule_id": "b"}\n{"rule_id": "a"}\n',
33
+ )
34
+ commit_tracked_files(repository_root)
35
+
36
+ exit_code, stdout_text, _stderr_text = run_policy(repository_root)
37
+
38
+ assert exit_code == FINDINGS_EXIT_CODE
39
+ assert (
40
+ f"{CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES}: {_LEDGER_RELATIVE_PATH}:"
41
+ in stdout_text
42
+ )
43
+ assert "cde followup dedupe" in stdout_text
44
+
45
+
46
+ def test_should_pass_a_committed_ledger_without_repeats(tmp_path: Path) -> None:
47
+ repository_root = tmp_path / "repo"
48
+ initialize_repository(repository_root)
49
+ write_text(
50
+ repository_root / _LEDGER_RELATIVE_PATH,
51
+ '{"rule_id": "a"}\n{"rule_id": "b"}\n',
52
+ )
53
+ commit_tracked_files(repository_root)
54
+
55
+ exit_code, stdout_text, _stderr_text = run_policy(repository_root)
56
+
57
+ assert exit_code == SUCCESS_EXIT_CODE
58
+ assert stdout_text == ""
@@ -17,6 +17,7 @@ import repository_policy
17
17
  from repository_checks.config.constants import (
18
18
  CHECK_ID_CLAUDE_MD_ORPHANS,
19
19
  CHECK_ID_ENV_VAR_DOCUMENTATION,
20
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
20
21
  CHECK_ID_PACKAGE_INVENTORY,
21
22
  CHECK_ID_PYTEST_TESTPATHS,
22
23
  CHECK_ID_TRACKED_PERSONAL_DATA,
@@ -50,6 +51,7 @@ def test_should_keep_stable_check_identifiers() -> None:
50
51
  assert repository_policy.ALL_CHECK_IDS == (
51
52
  CHECK_ID_CLAUDE_MD_ORPHANS,
52
53
  CHECK_ID_ENV_VAR_DOCUMENTATION,
54
+ CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
53
55
  CHECK_ID_PACKAGE_INVENTORY,
54
56
  CHECK_ID_PYTEST_TESTPATHS,
55
57
  CHECK_ID_TRACKED_PERSONAL_DATA,
package/settings.json CHANGED
@@ -7,6 +7,15 @@
7
7
  ]
8
8
  },
9
9
  "advisorModel": "fable",
10
+ "autoCompactWindow": 350000,
11
+ "modelSettings": {
12
+ "claude-haiku-5-5": {
13
+ "autoCompactWindow": 100000
14
+ },
15
+ "claude-haiku-4-5": {
16
+ "autoCompactWindow": 100000
17
+ }
18
+ },
10
19
  "autoMode": {
11
20
  "allow": [
12
21
  "$defaults",