claude-dev-env 8.78.3 → 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.
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +13 -3
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +2 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +15 -14
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +133 -3
- package/scripts/test_account_broker_support.py +56 -0
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
package/docs/account-broker.md
CHANGED
|
@@ -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
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
390
|
-
for
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/conftest.py
CHANGED
|
@@ -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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
6
|
-
import
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
+
create_directory_link(
|
|
45
35
|
from_link=managed_root / SCRIPTS_DIRECTORY_NAME,
|
|
46
|
-
to_target=
|
|
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 / "
|
|
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
|
-
|
|
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"],
|