claude-dev-env 8.78.2 → 8.79.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.
@@ -1,12 +1,37 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
3
4
 
4
- import { mergeHooksIntoSettings, pruneManagedHooksFromSettings } from './install.mjs';
5
+ import { isManagedPromptHook, mergeHooksIntoSettings, pruneManagedHooksFromSettings } from './install.mjs';
6
+
7
+ const SHIPPED_HOOKS_CONFIG = JSON.parse(readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8'));
5
8
 
6
9
  const REPLY_MATCHER = 'mcp__hearthbot__reply';
7
- const MANAGED_STATUS = 'claude-dev-env: checking each claim for its source';
10
+ const EXAMPLE_HOME = '/home/example';
8
11
  const USER_PROMPT_HOOK = { type: 'prompt', prompt: 'User check. $ARGUMENTS', statusMessage: 'my own check' };
9
12
 
13
+ function modelHookEntries(hooksConfig) {
14
+ return Object.entries(hooksConfig.hooks).flatMap(([eventName, matcherGroups]) => matcherGroups.flatMap(
15
+ eachGroup => (eachGroup.hooks ?? [])
16
+ .filter(eachHook => typeof eachHook.command !== 'string')
17
+ .map(eachHook => ({ eventName, matcher: eachGroup.matcher ?? '', hook: eachHook })),
18
+ ));
19
+ }
20
+
21
+ function shippedReplyJudgeEntries() {
22
+ return modelHookEntries(SHIPPED_HOOKS_CONFIG).filter(
23
+ eachEntry => eachEntry.eventName === 'PreToolUse' && eachEntry.matcher.split('|').includes(REPLY_MATCHER),
24
+ );
25
+ }
26
+
27
+ const [SHIPPED_REPLY_JUDGE_ENTRY] = shippedReplyJudgeEntries();
28
+ assert.ok(SHIPPED_REPLY_JUDGE_ENTRY, 'the shipped hooks.json needs a claim judge on reply');
29
+ const MANAGED_STATUS = SHIPPED_REPLY_JUDGE_ENTRY.hook.statusMessage;
30
+
31
+ function mergeInto(settings, hooksConfig) {
32
+ mergeHooksIntoSettings(settings, hooksConfig, `${EXAMPLE_HOME}/.claude`, 'python3', null, EXAMPLE_HOME);
33
+ }
34
+
10
35
  function hooksConfigWithPrompt(promptText) {
11
36
  return {
12
37
  hooks: {
@@ -23,14 +48,14 @@ function hooksConfigWithPrompt(promptText) {
23
48
  function replyHooks(settings) {
24
49
  return (settings.hooks?.PreToolUse ?? [])
25
50
  .filter(eachGroup => eachGroup.matcher === REPLY_MATCHER)
26
- .flatMap(eachGroup => eachGroup.hooks);
51
+ .flatMap(eachGroup => eachGroup.hooks ?? []);
27
52
  }
28
53
 
29
54
  test('should keep one managed prompt hook across reinstalls and carry the new prompt text', () => {
30
55
  const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
31
56
 
32
- mergeHooksIntoSettings(settings, hooksConfigWithPrompt('First judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
33
- mergeHooksIntoSettings(settings, hooksConfigWithPrompt('Second judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
57
+ mergeInto(settings, hooksConfigWithPrompt('First judge. $ARGUMENTS'));
58
+ mergeInto(settings, hooksConfigWithPrompt('Second judge. $ARGUMENTS'));
34
59
 
35
60
  assert.deepEqual(
36
61
  replyHooks(settings).map(eachHook => eachHook.prompt),
@@ -40,9 +65,41 @@ test('should keep one managed prompt hook across reinstalls and carry the new pr
40
65
 
41
66
  test('should remove the managed prompt hook on uninstall and keep the user prompt hook', () => {
42
67
  const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
43
- mergeHooksIntoSettings(settings, hooksConfigWithPrompt('Judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
68
+ mergeInto(settings, hooksConfigWithPrompt('Judge. $ARGUMENTS'));
44
69
 
45
70
  pruneManagedHooksFromSettings(settings, new Set());
46
71
 
47
72
  assert.deepEqual(replyHooks(settings), [USER_PROMPT_HOOK]);
48
73
  });
74
+
75
+ test('should mark the shipped claim judge on reply, and any other shipped model hook, as managed', () => {
76
+ assert.ok(modelHookEntries(SHIPPED_HOOKS_CONFIG).every(eachEntry => isManagedPromptHook(eachEntry.hook)));
77
+ });
78
+
79
+ test('should keep the shipped claim judge once, under its own event and matcher, after two installs', () => {
80
+ const settings = {};
81
+ mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
82
+ mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
83
+
84
+ assert.deepEqual(modelHookEntries(settings), modelHookEntries(SHIPPED_HOOKS_CONFIG));
85
+ });
86
+
87
+ test('should drop a managed prompt hook left under a retired matcher of an event the shipped config lists', () => {
88
+ const staleMatcher = 'mcp__hearthbot__retired';
89
+ const judgeOnlyMatcher = 'mcp__hearthbot__retired_alone';
90
+ const oldJudge = { type: 'prompt', prompt: 'Old judge. $ARGUMENTS', statusMessage: MANAGED_STATUS };
91
+ const settings = {
92
+ hooks: {
93
+ PreToolUse: [
94
+ { matcher: staleMatcher, hooks: [oldJudge, USER_PROMPT_HOOK] },
95
+ { matcher: judgeOnlyMatcher, hooks: [oldJudge] },
96
+ ],
97
+ },
98
+ };
99
+
100
+ mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
101
+
102
+ const staleGroup = settings.hooks.PreToolUse.find(eachGroup => eachGroup.matcher === staleMatcher);
103
+ assert.deepEqual(staleGroup.hooks, [USER_PROMPT_HOOK]);
104
+ assert.equal(settings.hooks.PreToolUse.some(eachGroup => eachGroup.matcher === judgeOnlyMatcher), false);
105
+ });
@@ -18,7 +18,7 @@ python scripts/account_broker.py check --product codex
18
18
  printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
19
19
  ```
20
20
 
21
- `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
21
+ `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
22
22
 
23
23
  `check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
24
24
 
@@ -0,0 +1,114 @@
1
+ """Deny a decision card or question that offers a merge over open review findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if hooks_directory not in sys.path:
10
+ sys.path.insert(0, hooks_directory)
11
+
12
+ from hooks_constants.hook_specific_output_keys import (
13
+ HOOK_EVENT_NAME_KEY,
14
+ HOOK_SPECIFIC_OUTPUT_KEY,
15
+ PERMISSION_DECISION_KEY,
16
+ )
17
+ from hooks_constants.merge_over_review_offer_gate_constants import (
18
+ ALL_CARD_TEXT_FIELDS,
19
+ ALL_OPTION_TEXT_FIELDS,
20
+ CLEAN_REVIEW_PATTERN,
21
+ DENY_DECISION,
22
+ HOOK_EVENT_NAME,
23
+ MERGE_OFFER_PATTERN,
24
+ MERGE_OVER_REVIEW_OFFER_REASON,
25
+ OPEN_REVIEW_PATTERN,
26
+ OPTIONS_FIELD,
27
+ PERMISSION_DECISION_REASON_KEY,
28
+ QUESTIONS_FIELD,
29
+ TEXT_SEPARATOR,
30
+ TOOL_INPUT_KEY,
31
+ )
32
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
33
+
34
+
35
+ def _one_option_text(each_option: object) -> str:
36
+ if isinstance(each_option, dict):
37
+ return TEXT_SEPARATOR.join(
38
+ str(each_option.get(each_field, "")) for each_field in ALL_OPTION_TEXT_FIELDS
39
+ )
40
+ return str(each_option)
41
+
42
+
43
+ def _block_texts(block_by_key: dict[str, object]) -> list[str]:
44
+ all_texts = [str(block_by_key.get(each_field, "")) for each_field in ALL_CARD_TEXT_FIELDS]
45
+ all_options = block_by_key.get(OPTIONS_FIELD)
46
+ if isinstance(all_options, list):
47
+ all_texts.extend(_one_option_text(each_option) for each_option in all_options)
48
+ return all_texts
49
+
50
+
51
+ def _card_text(card_by_key: dict[str, object]) -> str:
52
+ all_questions = card_by_key.get(QUESTIONS_FIELD)
53
+ if not isinstance(all_questions, list):
54
+ all_questions = []
55
+ all_blocks = [card_by_key] + [
56
+ each_question for each_question in all_questions if isinstance(each_question, dict)
57
+ ]
58
+ return TEXT_SEPARATOR.join(
59
+ each_text for each_block in all_blocks for each_text in _block_texts(each_block)
60
+ )
61
+
62
+
63
+ def offers_merge_over_review(card_by_key: dict[str, object]) -> bool:
64
+ """Return whether a card offers a merge while it names open review findings.
65
+
66
+ The whole card is read as one text: every question, header, context,
67
+ reason, and option, across both card shapes, an ``ask_decision`` card and
68
+ an ``AskUserQuestion`` questions list.
69
+
70
+ ::
71
+
72
+ option "Merge now", context "9 findings remain" -> True
73
+ question 1 "The review reports 9 findings.",
74
+ question 2 "Merge it now?" with "Yes" / "No" -> True
75
+ context "I can merge it now, but 9 findings remain." -> True
76
+ option "Join the merge queue", context "9 findings" -> True
77
+ context "The merge review check reports 11 findings",
78
+ options "Keep fixing" / "Split it" -> False
79
+ option "Merge", context "the review found no findings" -> False
80
+
81
+ Args:
82
+ card_by_key: The card's tool input.
83
+ """
84
+ card_text = _card_text(card_by_key)
85
+ if not MERGE_OFFER_PATTERN.search(card_text):
86
+ return False
87
+ return OPEN_REVIEW_PATTERN.search(CLEAN_REVIEW_PATTERN.sub("", card_text)) is not None
88
+
89
+
90
+ def decision_for(payload_by_key: dict[str, object]) -> dict[str, object] | None:
91
+ """Return a deny for a card that offers a merge over open findings, else None.
92
+
93
+ Args:
94
+ payload_by_key: The parsed PreToolUse input.
95
+ """
96
+ card_by_key = payload_by_key.get(TOOL_INPUT_KEY)
97
+ if not isinstance(card_by_key, dict) or not offers_merge_over_review(card_by_key):
98
+ return None
99
+ return {
100
+ HOOK_SPECIFIC_OUTPUT_KEY: {
101
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
102
+ PERMISSION_DECISION_KEY: DENY_DECISION,
103
+ PERMISSION_DECISION_REASON_KEY: MERGE_OVER_REVIEW_OFFER_REASON,
104
+ }
105
+ }
106
+
107
+
108
+ def main() -> int:
109
+ """Read one payload, print a deny when needed, and exit zero."""
110
+ return print_decision_from_stdin(decision_for)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sys.exit(main())
@@ -0,0 +1,138 @@
1
+ """Behavior tests for the merge-over-review decision card gate."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
9
+ if str(HOOKS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
11
+
12
+ from blocking import merge_over_review_offer_gate as gate
13
+
14
+ GATE_SCRIPT = Path(gate.__file__)
15
+
16
+
17
+ def _card(context: str, *options: tuple[str, str]) -> dict[str, object]:
18
+ return {
19
+ "question": "What next for the pull request?",
20
+ "context": context,
21
+ "options": [{"label": label, "consequence": consequence} for label, consequence in options],
22
+ }
23
+
24
+
25
+ def test_merge_option_over_open_findings_should_be_offered_over_review() -> None:
26
+ card = _card("The review found 9 findings.", ("Merge now", "It lands today."), ("Keep looping", "I rerun."))
27
+ assert gate.offers_merge_over_review(card)
28
+
29
+
30
+ def test_merge_option_that_overrides_a_status_should_be_offered_over_review() -> None:
31
+ card = _card("Tests pass.", ("Merge now", "You override the status."))
32
+ assert gate.offers_merge_over_review(card)
33
+
34
+
35
+ def test_card_without_a_merge_option_should_pass() -> None:
36
+ card = _card("The review found 9 findings.", ("Keep looping", "I rerun."), ("Drop shell checks", "Smaller diff."))
37
+ assert not gate.offers_merge_over_review(card)
38
+
39
+
40
+ def test_merge_option_after_a_clean_review_should_pass() -> None:
41
+ card = _card("The review pass on the head came back clean.", ("Merge", "It lands."), ("Hold", "It waits."))
42
+ assert not gate.offers_merge_over_review(card)
43
+
44
+
45
+ def test_merge_question_with_yes_no_options_over_unresolved_threads_should_be_offered_over_review() -> None:
46
+ card = {"question": "Merge PR 41 now?", "context": "3 unresolved review threads.", "options": ["Yes", "No"]}
47
+ assert gate.offers_merge_over_review(card)
48
+
49
+
50
+ def test_string_options_should_be_read() -> None:
51
+ card = {"question": "What next?", "context": "9 findings remain.", "options": ["Merge now", "Keep looping"]}
52
+ assert gate.offers_merge_over_review(card)
53
+
54
+
55
+ def test_merge_queue_option_over_findings_should_be_offered_over_review() -> None:
56
+ card = _card("9 findings remain.", ("Join the merge queue", "It waits for checks."), ("Wait", "It waits."))
57
+ assert gate.offers_merge_over_review(card)
58
+
59
+
60
+ def test_merge_offer_in_context_over_findings_should_be_offered_over_review() -> None:
61
+ card = {
62
+ "question": "Go ahead?",
63
+ "context": "The PR is green and I can merge it now, but the review reports 9 findings.",
64
+ "options": ["Yes", "No"],
65
+ }
66
+ assert gate.offers_merge_over_review(card)
67
+
68
+
69
+ def test_offer_and_findings_in_separate_questions_should_be_offered_over_review() -> None:
70
+ card = {
71
+ "questions": [
72
+ {"question": "The review reports 9 findings. Which next?", "options": ["Keep fixing", "Split it"]},
73
+ {"question": "Merge it now?", "options": ["Yes", "No"]},
74
+ ]
75
+ }
76
+ assert gate.offers_merge_over_review(card)
77
+
78
+
79
+ def test_merge_option_over_unaddressed_review_comments_should_be_offered_over_review() -> None:
80
+ card = _card("3 review comments are unaddressed; 2 changes requested.", ("Merge now", "It lands."))
81
+ assert gate.offers_merge_over_review(card)
82
+
83
+
84
+ def test_ship_card_about_a_pending_translation_should_pass() -> None:
85
+ card = {
86
+ "question": "Ship the README update now? The pending translation can follow.",
87
+ "options": ["Ship it", "Wait"],
88
+ }
89
+ assert not gate.offers_merge_over_review(card)
90
+
91
+
92
+ def test_ask_user_question_merge_option_over_findings_should_be_offered_over_review() -> None:
93
+ card = {
94
+ "questions": [
95
+ {
96
+ "question": "The review failed with 2 findings. What next?",
97
+ "header": "Next step",
98
+ "options": [
99
+ {"label": "Squash and merge", "description": "It lands now."},
100
+ {"label": "Fix first", "description": "I fix both."},
101
+ ],
102
+ }
103
+ ]
104
+ }
105
+ assert gate.offers_merge_over_review(card)
106
+
107
+
108
+ def test_card_about_the_merge_review_check_without_a_merge_offer_should_pass() -> None:
109
+ card = _card(
110
+ "The Merge review check reports 11 findings.", ("Keep fixing", "I rerun."), ("Split the PR", "Smaller diff.")
111
+ )
112
+ assert not gate.offers_merge_over_review(card)
113
+
114
+
115
+ def test_merge_option_after_a_review_with_no_findings_should_pass() -> None:
116
+ card = _card("The review on the head found no findings.", ("Merge", "It lands."), ("Hold", "It waits."))
117
+ assert not gate.offers_merge_over_review(card)
118
+
119
+
120
+ def test_decision_for_should_return_none_for_a_clean_card() -> None:
121
+ payload = {"tool_name": "AskUserQuestion", "tool_input": _card("All checks pass.", ("Merge", "It lands."))}
122
+ assert gate.decision_for(payload) is None
123
+
124
+
125
+ def test_script_should_print_a_deny_for_a_merge_over_findings_card() -> None:
126
+ payload = {
127
+ "tool_name": "mcp__hearthbot__ask_decision",
128
+ "tool_input": _card("9 findings remain.", ("Merge now", "It lands.")),
129
+ }
130
+ completed = subprocess.run(
131
+ [sys.executable, str(GATE_SCRIPT)],
132
+ input=json.dumps(payload),
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ )
137
+ assert completed.returncode == 0
138
+ assert json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -13,6 +13,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
15
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
16
+ - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
16
17
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
17
18
 
18
19
  ## When it fires
@@ -26,6 +27,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
26
27
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
27
28
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
28
29
  - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
30
+ - `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
29
31
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
30
32
 
31
33
  ## Proving it
@@ -43,6 +45,7 @@ Preconditions:
43
45
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
44
46
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
45
47
  - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
48
+ - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
46
49
  - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
47
50
 
48
51
  ## Gotchas
package/hooks/hooks.json CHANGED
@@ -164,6 +164,16 @@
164
164
  }
165
165
  ]
166
166
  },
167
+ {
168
+ "matcher": "AskUserQuestion|mcp__.*__ask_decision",
169
+ "hooks": [
170
+ {
171
+ "type": "command",
172
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/merge_over_review_offer_gate.py",
173
+ "timeout": 10
174
+ }
175
+ ]
176
+ },
167
177
  {
168
178
  "matcher": "mcp__.*__(add_issue_comment|issue_write|update_issue_comment)",
169
179
  "hooks": [
@@ -0,0 +1,34 @@
1
+ """Constants for the gate that keeps merge-over-review offers off decision cards."""
2
+
3
+ import re
4
+
5
+ TOOL_INPUT_KEY = "tool_input"
6
+ HOOK_EVENT_NAME = "PreToolUse"
7
+ DENY_DECISION = "deny"
8
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
9
+ ALL_CARD_TEXT_FIELDS = ("question", "header", "context", "reason")
10
+ QUESTIONS_FIELD = "questions"
11
+ OPTIONS_FIELD = "options"
12
+ TEXT_SEPARATOR = " "
13
+ ALL_OPTION_TEXT_FIELDS = ("label", "consequence", "description")
14
+ MERGE_OFFER_PATTERN = re.compile(
15
+ r"\b(?:merg(?:e|es|ing)|squash(?:es|ing)?|auto-merge)\b"
16
+ r"(?!\s+(?:review|check|base|conflicts?|state|status|gate)\b)",
17
+ re.IGNORECASE,
18
+ )
19
+ CLEAN_REVIEW_PATTERN = re.compile(
20
+ r"\b(?:no|zero|0)\s+(?:open\s+|review\s+|blocking\s+)?(?:findings?|failures?|threads?|comments?)\b",
21
+ re.IGNORECASE,
22
+ )
23
+ OPEN_REVIEW_PATTERN = re.compile(
24
+ r"\b(?:findings?|override|red|failing|failed|failures?|not clean|unresolved|unaddressed"
25
+ r"|changes requested|review comments?|open (?:review )?threads?|issues? (?:left|remain(?:ing)?)"
26
+ r"|\d+ remaining)\b",
27
+ re.IGNORECASE,
28
+ )
29
+ MERGE_OVER_REVIEW_OFFER_REASON = (
30
+ "This card offers a merge while it names open review findings, a red check, or an"
31
+ " override. A pull request merges only after a review pass on its head reports zero"
32
+ " findings. Drop the merge offer, keep fixing and rerunning the review, and ask only"
33
+ " about choices that keep that bar."
34
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.78.2",
3
+ "version": "8.79.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@ from dev_env_scripts_constants.account_broker_constants import (
47
47
  _wait_decision,
48
48
  ALL_PARENT_CLAUDE_SESSION_VARIABLES,
49
49
  COMMAND_MISSING_EXIT_CODE,
50
+ PLACEHOLDER_NAME_SLOT,
50
51
  REPORT_INDENT_SPACES,
51
52
  TIMEOUT_EXIT_CODE,
52
53
  WAIT_EXIT_CODE,
@@ -102,6 +103,7 @@ def decision_payload(decision: Decision) -> dict[str, object]:
102
103
  return {
103
104
  "action": decision.action,
104
105
  "account": decision.account.name if decision.account else None,
106
+ "profile": decision.account.profile if decision.account else None,
105
107
  "home": str(decision.account.home) if decision.account else None,
106
108
  "reason": decision.reason,
107
109
  "tier": decision.tier,
@@ -121,6 +123,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
121
123
  return [
122
124
  {
123
125
  "name": each_reading.account.name,
126
+ "profile": each_reading.account.profile,
124
127
  "home": str(each_reading.account.home),
125
128
  "is_main": each_reading.account.is_main,
126
129
  "priority": each_reading.account.priority,
@@ -150,11 +153,16 @@ def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, ob
150
153
  return frozenset(spent), spent
151
154
 
152
155
 
153
- def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
156
+ def _record_spent(all_state: dict[str, object], reading: Reading, now: datetime, reset: datetime | None = None) -> datetime:
154
157
  until = reset or _account_reset(reading, is_spent=True) or now + timedelta(hours=1)
155
158
  if until <= now:
156
159
  until = now + timedelta(hours=1)
157
160
  all_state["spent"][_state_key(reading.account)] = until.timestamp()
161
+ return until
162
+
163
+
164
+ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime) -> datetime:
165
+ until = _record_spent(all_state, reading, now)
158
166
  _save_state(broker_state_path(), all_state)
159
167
  return until
160
168
 
@@ -162,8 +170,7 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, r
162
170
  def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
163
171
  marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
164
172
  all_roster_names = {each_reading.account.name for each_reading in all_readings}
165
- prefix = f"{product.value}:"
166
- placeholder_suffix = f":{Path()}"
173
+ prefix, _, placeholder_suffix = _state_key(Account(product, PLACEHOLDER_NAME_SLOT, Path(), False)).partition(PLACEHOLDER_NAME_SLOT)
167
174
  all_resets_by_name = {}
168
175
  for each_key, each_raw in marks.items():
169
176
  if not (isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix)):
@@ -376,25 +383,30 @@ def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, pr
376
383
  try:
377
384
  reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
378
385
  except (ValueError, OSError, OverflowError) as error:
379
- raise BrokerConfigurationError(f"invalid reset for account {name}") from error
386
+ raise BrokerConfigurationError(f"invalid reset in spent mark {mark!r}") from error
380
387
  reading = all_accounts_by_name.get(name)
381
388
  if reading is None:
382
- if all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME:
383
- print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
384
389
  return Reading(Account(product, name, Path(), False), None), reset
385
390
  return reading, reset
386
391
 
387
392
 
388
393
  def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime, product: Product) -> None:
389
- all_accounts_by_name = {each_reading.account.name: each_reading for each_reading in all_readings}
390
- for each_mark in all_marks:
391
- reading, reset = _parse_spent_mark(all_accounts_by_name, each_mark, product)
392
- _mark_spent(all_state, reading, now, reset)
394
+ all_accounts_by_profile = {each_reading.account.profile: each_reading for each_reading in all_readings if each_reading.account.profile}
395
+ all_accounts_by_name = all_accounts_by_profile | {each_reading.account.name: each_reading for each_reading in all_readings}
396
+ all_parsed_marks = [_parse_spent_mark(all_accounts_by_name, each_mark, product) for each_mark in all_marks]
397
+ for each_reading, each_reset in all_parsed_marks:
398
+ _record_spent(all_state, each_reading, now, each_reset)
399
+ if all_parsed_marks:
400
+ _save_state(broker_state_path(), all_state)
401
+ for each_reading, _ in all_parsed_marks:
402
+ name = each_reading.account.name
403
+ if name not in all_accounts_by_name and (all_accounts_by_name or name != NO_ROSTER_ACCOUNT_NAME):
404
+ print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
393
405
 
394
406
 
395
407
  def _accounts_cli(product: Product) -> int:
396
408
  roster = all_product_adapters[product].load_accounts()
397
- print(json.dumps({"accounts": [{"name": each_account.name, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
409
+ print(json.dumps({"accounts": [{"name": each_account.name, "profile": each_account.profile, "home": str(each_account.home), "is_main": each_account.is_main} for each_account in roster]}))
398
410
  return 0
399
411
 
400
412
 
@@ -105,7 +105,7 @@ def _chain_entry(raw_entry: object, main_home: Path, chain_path: Path) -> Accoun
105
105
  if not isinstance(extra_args, list) or any(not isinstance(each_arg, str) for each_arg in extra_args):
106
106
  raise BrokerConfigurationError(f"invalid account list {chain_path}")
107
107
  home = Path(credential).expanduser().parent.resolve() if credential else main_home
108
- return Account(Product.CLAUDE, command, home, command=command)
108
+ return Account(Product.CLAUDE, command, home, command=command, profile=_home_profile(home))
109
109
 
110
110
 
111
111
  def _chain_entries(chain_path: Path, main_home: Path) -> tuple[Account, ...]:
@@ -169,11 +169,20 @@ def _with_priorities(all_accounts: Sequence[Account], all_ordered_names: Sequenc
169
169
  return tuple(dataclasses.replace(each_account, priority=priority_of(each_account)) for each_account in all_accounts)
170
170
 
171
171
 
172
+ def _home_profile(home: Path) -> str | None:
173
+ try:
174
+ profile_home = default_profile_home(home.name).resolve()
175
+ except ValueError:
176
+ return None
177
+ return home.name if profile_home == home else None
178
+
179
+
172
180
  def load_claude_accounts() -> tuple[Account, ...]:
173
181
  """Read the Claude account roster.
174
182
 
175
183
  Returns:
176
- Main and additional accounts in configured order, each with its place in the account order file.
184
+ Main and additional accounts in configured order, each with its place in the account order file
185
+ and, for a home under the profiles root, its profile name.
177
186
 
178
187
  Raises:
179
188
  BrokerConfigurationError: An account list is unreadable.
@@ -185,7 +194,8 @@ def load_claude_accounts() -> tuple[Account, ...]:
185
194
  _append_claude_account(all_accounts, all_seen, each_entry, main_home)
186
195
  for each_home in _extra_homes(main_home):
187
196
  home = each_home.resolve()
188
- _append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
197
+ extra_account = Account(Product.CLAUDE, home.name, home, command=home.name, profile=_home_profile(home))
198
+ _append_claude_account(all_accounts, all_seen, extra_account, main_home)
189
199
  return _with_priorities(all_accounts, _account_order(main_home))
190
200
 
191
201
 
@@ -18,7 +18,9 @@ is in place by the time any test module runs its top-level imports.
18
18
  from __future__ import annotations
19
19
 
20
20
  import importlib
21
+ import subprocess
21
22
  import sys
23
+ from collections.abc import Callable
22
24
  from pathlib import Path
23
25
 
24
26
  import pytest
@@ -52,3 +54,26 @@ def isolated_broker_state_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
52
54
  if getattr(each_module, "broker_state_path", None) is _live_broker_state_resolver:
53
55
  monkeypatch.setattr(each_module, "broker_state_path", lambda: isolated_path)
54
56
  return isolated_path
57
+
58
+
59
+ def _link_directory(*, from_link: Path, to_target: Path) -> None:
60
+ try:
61
+ importlib.import_module("claude_account_profile")._link_directory(to_target, from_link)
62
+ except subprocess.CalledProcessError as error:
63
+ link_output = (error.stderr or error.stdout or b"").decode(errors="replace").strip()
64
+ pytest.fail(f"directory link {from_link} -> {to_target} failed: {link_output}")
65
+ except OSError as error:
66
+ pytest.fail(f"directory link {from_link} -> {to_target} failed: {error}")
67
+
68
+
69
+ @pytest.fixture
70
+ def create_directory_link() -> Callable[..., None]:
71
+ """Return a helper that points a new directory link at a target folder.
72
+
73
+ Windows gets a junction, which needs no extra rights. Other systems get a
74
+ symlink. A link that cannot be made fails the test on either system.
75
+
76
+ Returns:
77
+ The helper, called as ``create_directory_link(from_link=..., to_target=...)``.
78
+ """
79
+ return _link_directory
@@ -56,6 +56,7 @@ WAIT_EXIT_CODE = 3
56
56
  COMMAND_MISSING_EXIT_CODE = 127
57
57
  TIMEOUT_EXIT_CODE = 124
58
58
  REPORT_INDENT_SPACES = 2
59
+ PLACEHOLDER_NAME_SLOT = "{name}"
59
60
  BROKER_STATE_DIRECTORY_NAME = "account-broker"
60
61
  BROKER_STATE_FILE_NAME = "state.json"
61
62
  BROKER_STATE_TEMP_SUFFIX = ".json"
@@ -150,6 +151,7 @@ class Account:
150
151
  is_main: bool = False
151
152
  command: str | None = None
152
153
  priority: int | None = None
154
+ profile: str | None = field(default=None, compare=False)
153
155
 
154
156
 
155
157
  @dataclass(frozen=True)
@@ -5,11 +5,11 @@ from __future__ import annotations
5
5
  import dataclasses
6
6
  import importlib
7
7
  import importlib.util
8
- import os
9
8
  import shutil
10
9
  import sys
11
10
  import types
12
11
  from datetime import datetime, timedelta, timezone
12
+ from collections.abc import Callable
13
13
  from pathlib import Path
14
14
 
15
15
  import pytest
@@ -21,7 +21,9 @@ from dev_env_scripts_constants.account_broker_constants import (
21
21
  ALL_CODEX_FLOORS,
22
22
  ALL_PARENT_CLAUDE_SESSION_VARIABLES,
23
23
  CMD_SHELL_METACHARACTERS,
24
+ Account,
24
25
  JobOutcome,
26
+ Product,
25
27
  )
26
28
  from dev_env_scripts_constants.claude_account_constants import (
27
29
  MAIN_SESSION_USED_CEILING_PERCENT,
@@ -113,16 +115,9 @@ def test_parse_utc_time_converts_an_offset_timestamp() -> None:
113
115
  assert parsed.isoformat() == "2026-10-03T11:30:00+00:00"
114
116
 
115
117
 
116
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
117
- if sys.platform.startswith("win32"):
118
- importlib.import_module("_winapi").CreateJunction(
119
- str(to_target), str(from_link)
120
- )
121
- return
122
- os.symlink(to_target, from_link, target_is_directory=True)
123
-
124
-
125
- def _build_install_layout(home: Path) -> tuple[Path, Path]:
118
+ def _build_install_layout(
119
+ home: Path, create_directory_link: Callable[..., None]
120
+ ) -> tuple[Path, Path]:
126
121
  agents_constants_directory = (
127
122
  home / ".agents" / "scripts" / "dev_env_scripts_constants"
128
123
  )
@@ -143,7 +138,7 @@ def _build_install_layout(home: Path) -> tuple[Path, Path]:
143
138
  (classifier_directory / "classifier_constants.py").write_text(
144
139
  f"ALL_USAGE_LIMIT_MARKERS = ({TEMP_TREE_MARKER!r},)\n", encoding="utf-8"
145
140
  )
146
- _create_directory_link(
141
+ create_directory_link(
147
142
  from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
148
143
  )
149
144
  linked_module = (
@@ -170,10 +165,10 @@ def _load_module_from(
170
165
 
171
166
  @pytest.mark.parametrize("entry_point", ("through_scripts_link", "through_agents_path"))
172
167
  def test_codex_usage_limit_signatures_finds_shared_tree_beside_the_scripts_link(
173
- entry_point: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
168
+ entry_point: str, tmp_path: Path, create_directory_link: Callable[..., None], monkeypatch: pytest.MonkeyPatch
174
169
  ) -> None:
175
170
  monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
176
- linked_module, agents_module = _build_install_layout(tmp_path / "home")
171
+ linked_module, agents_module = _build_install_layout(tmp_path / "home", create_directory_link)
177
172
  module_file = (
178
173
  linked_module if entry_point == "through_scripts_link" else agents_module
179
174
  )
@@ -188,3 +183,9 @@ def test_parent_session_variables_name_the_session_link_and_leave_the_account_ho
188
183
  assert "CLAUDE_CODE_SESSION_ID" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
189
184
  assert "CLAUDECODE" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
190
185
  assert "CLAUDE_CONFIG_DIR" not in ALL_PARENT_CLAUDE_SESSION_VARIABLES
186
+
187
+
188
+ def test_should_treat_accounts_that_differ_only_in_profile_as_one_account() -> None:
189
+ home = Path("/profiles/first")
190
+
191
+ assert Account(Product.CLAUDE, "first", home, profile="first") == Account(Product.CLAUDE, "first", home)
@@ -135,6 +135,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
135
135
  hooks/blocking/verify_before_acting.py -> exempt
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
138
+ hooks/blocking/merge_over_review_offer_gate.py -> exempt
138
139
  hooks/blocking/some_new_blocker.py -> flagged
139
140
 
140
141
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -166,6 +167,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
166
167
  The artifact page gate denies a page publish whose text is unreadable in
167
168
  dark mode. It decides how a page renders and no code or safety policy.
168
169
 
170
+ The merge offer gate denies a decision card whose text offers a merge and
171
+ names open review findings. It reads only the wording of a chat card and
172
+ decides no code or safety policy; the merge review gate holds the merge.
173
+
169
174
  Args:
170
175
  registered_string: One command, path, script, or entrypoint string.
171
176
  """
@@ -74,6 +74,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
74
74
  "blocking/image_read_size_gate.py",
75
75
  "blocking/artifact_dark_mode_gate.py",
76
76
  "blocking/haiku_spawn_slim_gate.py",
77
+ "blocking/merge_over_review_offer_gate.py",
77
78
  )
78
79
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
79
80
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -441,6 +441,7 @@ def test_should_print_decision_and_meters_for_choose(
441
441
  assert account_broker.main(("choose", "--product", "codex")) == 0
442
442
  payload = json.loads(capsys.readouterr().out)
443
443
  assert payload["decision"]["account"] == "roomy"
444
+ assert payload["decision"]["profile"] is None
444
445
  assert payload["accounts"][0]["meters"]["session_percent_left"] == 80
445
446
  assert payload["state_path"].endswith("state.json")
446
447
 
@@ -781,7 +782,7 @@ def test_should_list_accounts_without_reading_meters(
781
782
 
782
783
  assert account_broker.main(("accounts", "--product", "codex")) == 0
783
784
  assert json.loads(capsys.readouterr().out) == {
784
- "accounts": [{"name": "listed", "home": str(account.home), "is_main": False}]
785
+ "accounts": [{"name": "listed", "profile": None, "home": str(account.home), "is_main": False}]
785
786
  }
786
787
 
787
788
 
@@ -931,6 +932,59 @@ def test_should_mark_a_windows_launcher_path_account_spent(
931
932
  assert decision["account"] == "second"
932
933
 
933
934
 
935
+ def test_should_mark_an_account_spent_by_its_profile(
936
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
937
+ ) -> None:
938
+ launcher_name = "C:\\Users\\someone\\.local\\bin\\claude-first.cmd"
939
+ first = Account(Product.CLAUDE, launcher_name, Path("/profiles/first"), command=launcher_name, profile="first")
940
+ second = _account("second", Product.CLAUDE)
941
+ monkeypatch.setitem(
942
+ account_broker.all_product_adapters,
943
+ Product.CLAUDE,
944
+ _adapter((first, second), {launcher_name: _meters(80, 80), "second": _meters(80, 80)}),
945
+ )
946
+ _freeze_clock(monkeypatch, NOW)
947
+
948
+ code = account_broker.main(("choose", "--product", "claude", "--spent", f"first:{(NOW + timedelta(hours=2)).timestamp()}"))
949
+
950
+ decision = json.loads(capsys.readouterr().out)["decision"]
951
+ assert code == 0
952
+ assert decision["account"] == "second"
953
+
954
+
955
+ def test_should_mark_the_account_named_by_a_mark_that_is_also_another_profile(
956
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
957
+ ) -> None:
958
+ launcher_name = "C:\\Users\\someone\\.local\\bin\\claude-editor.cmd"
959
+ profiled = Account(Product.CLAUDE, launcher_name, Path("/profiles/second"), command=launcher_name, profile="second")
960
+ second = _account("second", Product.CLAUDE)
961
+ monkeypatch.setitem(
962
+ account_broker.all_product_adapters,
963
+ Product.CLAUDE,
964
+ _adapter((second, profiled), {launcher_name: _meters(80, 80), "second": _meters(90, 90)}),
965
+ )
966
+ _freeze_clock(monkeypatch, NOW)
967
+
968
+ code = account_broker.main(("choose", "--product", "claude", "--spent", f"second:{(NOW + timedelta(hours=2)).timestamp()}"))
969
+
970
+ decision = json.loads(capsys.readouterr().out)["decision"]
971
+ assert code == 0
972
+ assert decision["account"] == launcher_name
973
+
974
+
975
+ def test_should_save_no_spent_mark_when_a_later_mark_is_unreadable(
976
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
977
+ ) -> None:
978
+ account = _account("first")
979
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
980
+
981
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "first", "--spent", "guest:tomorrow"))
982
+
983
+ assert code == 2
984
+ assert "invalid reset" in capsys.readouterr().err
985
+ assert account_broker._load_state(account_broker.broker_state_path()).get("spent", {}) == {}
986
+
987
+
934
988
  def test_should_reject_an_invalid_spent_reset(
935
989
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
936
990
  ) -> None:
@@ -1044,6 +1098,22 @@ def test_should_run_past_a_stored_spent_mark_beyond_the_platform_range(
1044
1098
  assert decision["action"] == "run"
1045
1099
 
1046
1100
 
1101
+ def test_should_wait_on_an_outside_spent_mark_under_another_state_key_layout(
1102
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1103
+ ) -> None:
1104
+ account = _account("first")
1105
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1106
+ monkeypatch.setattr(account_broker, "_state_key", lambda marked: f"{marked.product.value}|{marked.name}|{marked.home}")
1107
+ _freeze_clock(monkeypatch, NOW)
1108
+ reset = NOW + timedelta(hours=2)
1109
+
1110
+ account_broker.main(("choose", "--product", "codex", "--spent", f"visitor:{int(reset.timestamp())}"))
1111
+
1112
+ decision = json.loads(capsys.readouterr().out)["decision"]
1113
+ assert decision["action"] == "wait"
1114
+ assert "account visitor outside the roster is spent" in decision["reason"]
1115
+
1116
+
1047
1117
  def test_should_name_the_outside_spent_mark_when_it_forces_a_wait(
1048
1118
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1049
1119
  ) -> None:
@@ -1106,6 +1176,37 @@ def test_should_warn_when_a_spent_mark_names_an_account_the_roster_lacks(
1106
1176
  assert "outside the roster" in all_warning_lines[0]
1107
1177
 
1108
1178
 
1179
+ def test_should_print_no_outside_roster_warning_when_a_later_mark_is_unreadable(
1180
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1181
+ ) -> None:
1182
+ account = _account("first")
1183
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1184
+
1185
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "visitor", "--spent", "first:notanumber"))
1186
+
1187
+ all_error_lines = capsys.readouterr().err.splitlines()
1188
+ assert code == 2
1189
+ assert len(all_error_lines) == 1
1190
+ assert "invalid reset in spent mark 'first:notanumber'" in all_error_lines[0]
1191
+ assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
1192
+
1193
+
1194
+ def test_should_save_an_outside_launcher_path_given_with_an_explicit_reset(
1195
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1196
+ ) -> None:
1197
+ account = _account("first")
1198
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1199
+ reset = int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp())
1200
+
1201
+ code = account_broker.main(("choose", "--product", "codex", "--spent", f"C:\\bin\\old.cmd:{reset}"))
1202
+
1203
+ all_warning_lines = capsys.readouterr().err.splitlines()
1204
+ all_saved_marks = account_broker._load_state(account_broker.broker_state_path())["spent"]
1205
+ assert code == WAIT_EXIT_CODE
1206
+ assert "C:\\bin\\old.cmd" in all_warning_lines[0]
1207
+ assert all_saved_marks == {f"codex:C:\\bin\\old.cmd:{Path()}": float(reset)}
1208
+
1209
+
1109
1210
  @pytest.mark.parametrize("mark", ["", ":1800000000"])
1110
1211
  def test_should_reject_a_spent_mark_without_an_account_name(
1111
1212
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], mark: str
@@ -1127,8 +1228,8 @@ def test_should_serialize_a_run_and_a_wait_decision() -> None:
1127
1228
  run_payload = account_broker.decision_payload(account_broker.Decision("run", account, None, "first has 80% left", "normal"))
1128
1229
  wait_payload = account_broker.decision_payload(account_broker.Decision("wait", None, reset, "no account has room", "wait"))
1129
1230
 
1130
- assert run_payload == {"action": "run", "account": "first", "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
1131
- assert wait_payload == {"action": "wait", "account": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
1231
+ assert run_payload == {"action": "run", "account": "first", "profile": None, "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
1232
+ assert wait_payload == {"action": "wait", "account": None, "profile": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
1132
1233
 
1133
1234
 
1134
1235
  def test_should_print_the_whole_rate_limit_result_for_one_home(
@@ -1236,6 +1337,12 @@ def test_should_hand_the_live_log_to_the_subprocess_runner(tmp_path: Path, monke
1236
1337
  assert captured_options["live_log"] == live_log
1237
1338
 
1238
1339
 
1340
+ def test_decision_payload_should_carry_the_profile_of_a_claude_account() -> None:
1341
+ account = Account(Product.CLAUDE, "C:\\bin\\claude-editor.cmd", Path("/profiles/editor"), command="C:\\bin\\claude-editor.cmd", profile="editor")
1342
+
1343
+ assert account_broker.decision_payload(account_broker.Decision("run", account, None, "most room", "normal"))["profile"] == "editor"
1344
+
1345
+
1239
1346
  def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1240
1347
  account = _account("codex-2")
1241
1348
  decision = account_broker.Decision("run", account, None, "most room", "normal")
@@ -1243,6 +1350,7 @@ def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1243
1350
  assert account_broker.decision_payload(decision) == {
1244
1351
  "action": "run",
1245
1352
  "account": "codex-2",
1353
+ "profile": None,
1246
1354
  "home": str(Path("/profiles") / "codex-2"),
1247
1355
  "reason": "most room",
1248
1356
  "tier": "normal",
@@ -1259,3 +1367,25 @@ def test_decision_payload_should_leave_the_account_empty_and_give_the_reset_for_
1259
1367
  assert payload["account"] is None
1260
1368
  assert payload["home"] is None
1261
1369
  assert payload["resets_at"] == "2026-10-03T08:00:00+00:00"
1370
+
1371
+
1372
+ def test_should_save_every_spent_mark_in_one_write(
1373
+ monkeypatch: pytest.MonkeyPatch,
1374
+ ) -> None:
1375
+ accounts = (_account("first"), _account("second"))
1376
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
1377
+ "first": _meters(80, 80), "second": _meters(70, 70)
1378
+ }))
1379
+ all_saved_marks: list[dict[str, object]] = []
1380
+
1381
+ def save_or_fail(path: Path, all_state: dict[str, object]) -> None:
1382
+ all_saved_marks.append(dict(all_state["spent"]))
1383
+ raise OSError("disk full")
1384
+
1385
+ monkeypatch.setattr(account_broker, "_save_state", save_or_fail)
1386
+
1387
+ with pytest.raises(OSError):
1388
+ account_broker.main(("choose", "--product", "codex", "--spent", "first", "--spent", "second"))
1389
+
1390
+ assert len(all_saved_marks) == 1
1391
+ assert len(all_saved_marks[0]) == 2
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  import time
10
10
  from datetime import datetime, timezone
11
+ from collections.abc import Callable
11
12
  from pathlib import Path
12
13
  from types import SimpleNamespace
13
14
 
@@ -45,6 +46,61 @@ def _claude_home_with_extras(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ex
45
46
  return main_home
46
47
 
47
48
 
49
+ def test_should_name_a_chain_account_profile_from_its_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
50
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
51
+ monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
52
+ main_home = tmp_path / ".claude"
53
+ main_home.mkdir()
54
+ profiles_root = tmp_path / ".claude-profiles"
55
+ launcher = "C:\\bin\\claude-editor.cmd"
56
+ (main_home / "claude-chain.json").write_text(
57
+ json.dumps(
58
+ {
59
+ "chain": [
60
+ {"command": launcher, "credentials_path": str(profiles_root / "editor" / ".credentials.json")},
61
+ {"command": "C:\\bin\\other.cmd", "credentials_path": str(tmp_path / "elsewhere" / ".credentials.json")},
62
+ {"command": "C:\\bin\\reserved.cmd", "credentials_path": str(profiles_root / "main" / ".credentials.json")},
63
+ ]
64
+ }
65
+ ),
66
+ encoding="utf-8",
67
+ )
68
+ (main_home / "extra-profiles.json").write_text('["extra"]', encoding="utf-8")
69
+
70
+ accounts = support.load_claude_accounts()
71
+
72
+ assert [(each_account.name, each_account.profile) for each_account in accounts] == [
73
+ ("main", None),
74
+ (launcher, "editor"),
75
+ ("C:\\bin\\other.cmd", None),
76
+ ("C:\\bin\\reserved.cmd", None),
77
+ ("extra", "extra"),
78
+ ]
79
+
80
+
81
+ @pytest.mark.parametrize(
82
+ ("target_parts", "expected_profile"),
83
+ [(("claude-profiles-link-target", "target"), None), ((".claude-profiles", "target"), "target")],
84
+ )
85
+ def test_should_name_an_extra_account_profile_from_its_resolved_home(
86
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, create_directory_link: Callable[..., None], target_parts: tuple[str, str], expected_profile: str | None
87
+ ) -> None:
88
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
89
+ monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
90
+ main_home = tmp_path / ".claude"
91
+ main_home.mkdir()
92
+ profiles_root = tmp_path / ".claude-profiles"
93
+ profiles_root.mkdir()
94
+ target = tmp_path.joinpath(*target_parts)
95
+ target.mkdir(parents=True)
96
+ create_directory_link(from_link=profiles_root / "alias", to_target=target)
97
+ (main_home / "extra-profiles.json").write_text('["alias"]', encoding="utf-8")
98
+
99
+ accounts = support.load_claude_accounts()
100
+
101
+ assert [(each_account.name, each_account.profile) for each_account in accounts[1:]] == [("target", expected_profile)]
102
+
103
+
48
104
  def test_should_leave_claude_priorities_unset_without_an_order_file(
49
105
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
50
106
  ) -> None:
@@ -2,12 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
6
- import os
7
5
  import subprocess
8
6
  import sys
9
7
  import urllib.error
10
8
  from datetime import datetime, timezone
9
+ from collections.abc import Callable
11
10
  from pathlib import Path
12
11
 
13
12
  import pytest
@@ -105,18 +104,11 @@ def test_probe_rejects_missing_access_token_for_another_account(
105
104
  usage.probe_account_meters(Path("home") / ".claude-profiles" / "ev" / ".credentials.json")
106
105
 
107
106
 
108
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
109
- if sys.platform.startswith("win32"):
110
- importlib.import_module("_winapi").CreateJunction(str(to_target), str(from_link))
111
- return
112
- os.symlink(to_target, from_link, target_is_directory=True)
113
-
114
-
115
- def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path) -> None:
107
+ def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
116
108
  home = tmp_path.resolve()
117
109
  (home / ".agents" / "scripts").mkdir(parents=True)
118
110
  (home / ".claude").mkdir()
119
- _create_directory_link(
111
+ create_directory_link(
120
112
  from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
121
113
  )
122
114
 
@@ -2,13 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
6
- import os
7
- import sys
5
+ import subprocess
6
+ from collections.abc import Callable
8
7
  from pathlib import Path
9
8
 
10
9
  import pytest
11
10
 
11
+ import claude_account_profile
12
12
  import shared_tree_paths
13
13
  from dev_env_scripts_constants.grok_worker_constants import (
14
14
  CLAUDE_CONFIG_DIR_ENV_VAR,
@@ -23,27 +23,17 @@ from dev_env_scripts_constants.shared_tree_constants import (
23
23
  )
24
24
 
25
25
 
26
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
27
- if sys.platform.startswith("win32"):
28
- importlib.import_module("_winapi").CreateJunction(
29
- str(to_target), str(from_link)
30
- )
31
- return
32
- try:
33
- os.symlink(to_target, from_link, target_is_directory=True)
34
- except OSError:
35
- pytest.skip("directory symlink creation is not available")
36
-
37
-
38
- def _build_junction_scripts_layout(tmp_path: Path) -> Path:
39
- real_scripts_directory = tmp_path / "real" / SCRIPTS_DIRECTORY_NAME
40
- real_scripts_directory.mkdir(parents=True)
41
- (real_scripts_directory / "runner.py").write_text("", encoding="utf-8")
26
+ def _build_junction_scripts_layout(
27
+ tmp_path: Path, create_directory_link: Callable[..., None]
28
+ ) -> Path:
29
+ linked_scripts_directory = tmp_path / "target" / SCRIPTS_DIRECTORY_NAME
30
+ linked_scripts_directory.mkdir(parents=True)
31
+ (linked_scripts_directory / "runner.py").write_text("", encoding="utf-8")
42
32
  managed_root = tmp_path / "root"
43
33
  managed_root.mkdir()
44
- _create_directory_link(
34
+ create_directory_link(
45
35
  from_link=managed_root / SCRIPTS_DIRECTORY_NAME,
46
- to_target=real_scripts_directory,
36
+ to_target=linked_scripts_directory,
47
37
  )
48
38
  return managed_root / SCRIPTS_DIRECTORY_NAME / "runner.py"
49
39
 
@@ -63,9 +53,9 @@ def _write_process_tree_kill_stub(from_root: Path) -> Path:
63
53
 
64
54
 
65
55
  def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
66
- tmp_path: Path,
56
+ tmp_path: Path, create_directory_link: Callable[..., None],
67
57
  ) -> None:
68
- module_file = _build_junction_scripts_layout(tmp_path)
58
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
69
59
  expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
70
60
  resolved_scripts_directory = (
71
61
  shared_tree_paths.resolve_shared_process_tree_scripts_directory(
@@ -76,10 +66,10 @@ def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
76
66
 
77
67
 
78
68
  def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
79
- tmp_path: Path,
69
+ tmp_path: Path, create_directory_link: Callable[..., None],
80
70
  ) -> None:
81
- module_file = _build_junction_scripts_layout(tmp_path)
82
- expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "real")
71
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
72
+ expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "target")
83
73
  resolved_scripts_directory = (
84
74
  shared_tree_paths.resolve_shared_process_tree_scripts_directory(
85
75
  module_file, all_environment={}
@@ -89,9 +79,9 @@ def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
89
79
 
90
80
 
91
81
  def test_configured_root_is_used_when_neither_parent_holds_the_module(
92
- tmp_path: Path,
82
+ tmp_path: Path, create_directory_link: Callable[..., None],
93
83
  ) -> None:
94
- module_file = _build_junction_scripts_layout(tmp_path)
84
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
95
85
  configured_root = tmp_path / "configured"
96
86
  expected_scripts_directory = _write_process_tree_kill_stub(configured_root)
97
87
  resolved_scripts_directory = (
@@ -106,9 +96,9 @@ def test_configured_root_is_used_when_neither_parent_holds_the_module(
106
96
 
107
97
 
108
98
  def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
109
- tmp_path: Path,
99
+ tmp_path: Path, create_directory_link: Callable[..., None],
110
100
  ) -> None:
111
- module_file = _build_junction_scripts_layout(tmp_path)
101
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
112
102
  expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
113
103
  configured_root = tmp_path / "configured"
114
104
  _write_process_tree_kill_stub(configured_root)
@@ -123,8 +113,8 @@ def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
123
113
  assert resolved_scripts_directory == expected_scripts_directory
124
114
 
125
115
 
126
- def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
127
- module_file = _build_junction_scripts_layout(tmp_path)
116
+ def test_missing_module_returns_unresolved_candidate(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
117
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
128
118
  expected_scripts_directory = (
129
119
  tmp_path
130
120
  / "root"
@@ -144,9 +134,9 @@ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
144
134
 
145
135
 
146
136
  def test_junction_scripts_dir_finds_advisor_scripts_on_unresolved_parent(
147
- tmp_path: Path,
137
+ tmp_path: Path, create_directory_link: Callable[..., None],
148
138
  ) -> None:
149
- module_file = _build_junction_scripts_layout(tmp_path)
139
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
150
140
  expected_scripts_directory = (
151
141
  tmp_path
152
142
  / "root"
@@ -198,7 +188,7 @@ def test_generalized_resolver_serves_a_deeper_anchor(tmp_path: Path) -> None:
198
188
 
199
189
  @pytest.mark.parametrize("managed_name", (".claude", "profile-a"))
200
190
  def test_resolved_agents_scripts_find_the_verified_managed_root(
201
- tmp_path: Path, managed_name: str,
191
+ tmp_path: Path, create_directory_link: Callable[..., None], managed_name: str,
202
192
  ) -> None:
203
193
  agents_name = ".agents" if managed_name == ".claude" else f"{managed_name}.agents"
204
194
  agents_root = tmp_path / agents_name
@@ -208,7 +198,7 @@ def test_resolved_agents_scripts_find_the_verified_managed_root(
208
198
  module_file.write_text("", encoding="utf-8")
209
199
  managed_root = tmp_path / managed_name
210
200
  managed_root.mkdir()
211
- _create_directory_link(from_link=managed_root / "scripts", to_target=scripts_directory)
201
+ create_directory_link(from_link=managed_root / "scripts", to_target=scripts_directory)
212
202
  expected = _write_process_tree_kill_stub(managed_root)
213
203
  resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
214
204
  assert resolved_directory == expected
@@ -227,3 +217,35 @@ def test_neighboring_managed_root_cannot_supply_a_foreign_shared_tree(tmp_path:
227
217
  resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
228
218
  expected = agents_root / "_shared" / "process-tree" / "scripts"
229
219
  assert resolved_directory == expected
220
+
221
+
222
+ def test_should_fail_with_the_link_command_output_when_a_link_cannot_be_made(
223
+ tmp_path: Path,
224
+ create_directory_link: Callable[..., None],
225
+ monkeypatch: pytest.MonkeyPatch,
226
+ ) -> None:
227
+ def refuse_link(source_path: Path, link_path: Path) -> None:
228
+ raise subprocess.CalledProcessError(1, ["mklink"], b"", b"Access is denied.")
229
+
230
+ monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
231
+
232
+ with pytest.raises(pytest.fail.Exception) as failure:
233
+ create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
234
+
235
+ assert "Access is denied." in str(failure.value)
236
+
237
+
238
+ def test_should_fail_with_the_os_error_when_a_symlink_cannot_be_made(
239
+ tmp_path: Path,
240
+ create_directory_link: Callable[..., None],
241
+ monkeypatch: pytest.MonkeyPatch,
242
+ ) -> None:
243
+ def refuse_link(source_path: Path, link_path: Path) -> None:
244
+ raise PermissionError("Operation not permitted")
245
+
246
+ monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
247
+
248
+ with pytest.raises(pytest.fail.Exception) as failure:
249
+ create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
250
+
251
+ assert "Operation not permitted" in str(failure.value)
@@ -58,6 +58,14 @@ def test_pull_request_lifecycle_gate_registration_is_clean(tmp_path: Path) -> No
58
58
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
59
59
 
60
60
 
61
+ def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
62
+ current_document = _hook_document(
63
+ ["hooks/blocking/merge_over_review_offer_gate.py"],
64
+ '{"hooks": {}}',
65
+ )
66
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
67
+
68
+
61
69
  def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
62
70
  current_document = _hook_document(
63
71
  ["hooks/blocking/step_note_gate.py"],