claude-dev-env 8.98.9 → 8.98.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/account-broker.md +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/codex-accounts.md +11 -0
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +9 -9
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/codex_account_choice.py +98 -6
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +17 -0
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_codex_account_choice.py +121 -0
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from open_task_asks import
|
|
5
|
+
from open_task_asks import claude_home, is_ask
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@pytest.fixture(autouse=True)
|
|
@@ -41,17 +41,10 @@ def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask:
|
|
|
41
41
|
assert is_ask(prompt_text) is is_expected_ask
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
assert
|
|
47
|
-
assert record_prompt("s-1", "Submit them") == "ask-2"
|
|
48
|
-
assert [each_prompt.ask_id for each_prompt in recorded_prompts("s-1")] == [
|
|
49
|
-
"ask-1",
|
|
50
|
-
None,
|
|
51
|
-
"ask-2",
|
|
52
|
-
]
|
|
44
|
+
def test_should_read_the_claude_home_from_claude_config_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
45
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "profile"))
|
|
46
|
+
assert claude_home() == tmp_path / "profile"
|
|
53
47
|
|
|
54
48
|
|
|
55
|
-
def
|
|
56
|
-
assert
|
|
57
|
-
assert recorded_prompts("../escape") == []
|
|
49
|
+
def test_should_default_the_claude_home_to_the_user_home(tmp_path: Path) -> None:
|
|
50
|
+
assert claude_home() == tmp_path / ".claude"
|
package/package.json
CHANGED
package/rules/index.md
CHANGED
|
@@ -24,7 +24,7 @@ Each line gives a rule in brief. When its trigger matches your task, open the gu
|
|
|
24
24
|
**Shipping**
|
|
25
25
|
- **Pull request lifecycle**. Before a commit, push, pull request action, or merge. Invoke the `pr-lifecycle` skill; a hook denies those commands until it loads.
|
|
26
26
|
- **Proof before a pull request** ([guide](../docs/rule-guides/proof-before-pull-request.md)). Before you open a pull request. Run the change with and without it, and quote both under "Proof in practice".
|
|
27
|
-
- **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>`
|
|
27
|
+
- **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>` when the status changes.
|
|
28
28
|
|
|
29
29
|
**Memory**
|
|
30
30
|
- **Durable facts only** ([guide](../docs/rule-guides/memory-stores-durable-facts.md)). Writing auto-memory or a `MEMORY.md` entry. Keep only a fact that helps a fresh session weeks later.
|
|
@@ -5,7 +5,9 @@ Each Codex account signs in under its own Codex home in
|
|
|
5
5
|
``~/.codex-profiles/<name>``. The account list names them in try order: the
|
|
6
6
|
``CODEX_ACCOUNT_PROFILES`` environment variable, comma-separated, else the saved
|
|
7
7
|
``account-launchers.json`` list under the profiles root. With neither,
|
|
8
|
-
``codex-1`` through ``codex-4`` are the roster.
|
|
8
|
+
``codex-1`` through ``codex-4`` are the roster. An environment roster that
|
|
9
|
+
differs from the saved file is saved to it, so a process without the variable
|
|
10
|
+
reads the same roster.
|
|
9
11
|
|
|
10
12
|
Three commands::
|
|
11
13
|
|
|
@@ -27,6 +29,7 @@ import argparse
|
|
|
27
29
|
import json
|
|
28
30
|
import os
|
|
29
31
|
import sys
|
|
32
|
+
import time
|
|
30
33
|
from collections.abc import Callable, Sequence
|
|
31
34
|
from datetime import datetime, timezone
|
|
32
35
|
from pathlib import Path
|
|
@@ -60,8 +63,12 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
60
63
|
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
61
64
|
ROSTER_JSON_INDENT,
|
|
62
65
|
ROSTER_NOT_A_LIST_TEMPLATE,
|
|
66
|
+
ROSTER_REPLACE_ATTEMPTS,
|
|
67
|
+
ROSTER_REPLACE_RETRY_SECONDS,
|
|
68
|
+
SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE,
|
|
63
69
|
SETUP_NAMES_SEPARATOR,
|
|
64
70
|
SETUP_NO_SAVED_NAMES_TEXT,
|
|
71
|
+
SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE,
|
|
65
72
|
SETUP_PROMPT_TEXT,
|
|
66
73
|
SETUP_SAVED_NAMES_TEMPLATE,
|
|
67
74
|
TEXT_ENCODING,
|
|
@@ -156,12 +163,44 @@ def _saved_roster_file_names(profiles_root: Path) -> tuple[str, ...]:
|
|
|
156
163
|
return _roster(document, str(roster_path))
|
|
157
164
|
|
|
158
165
|
|
|
166
|
+
def _saved_roster_or_nothing(profiles_root: Path) -> tuple[str, ...]:
|
|
167
|
+
try:
|
|
168
|
+
return _saved_roster_file_names(profiles_root)
|
|
169
|
+
except (CodexAccountNameError, OSError, UnicodeError):
|
|
170
|
+
return ()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _save_environment_roster(
|
|
174
|
+
profiles_root: Path, all_environment_names: tuple[str, ...]
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Copy the environment roster into the saved file when the two differ.
|
|
177
|
+
|
|
178
|
+
A process started without ``CODEX_ACCOUNT_PROFILES`` then reads the same
|
|
179
|
+
roster from the file.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
183
|
+
all_environment_names: The validated names the environment gives.
|
|
184
|
+
"""
|
|
185
|
+
if _saved_roster_or_nothing(profiles_root) == all_environment_names:
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
save_codex_account_names(profiles_root, all_environment_names)
|
|
189
|
+
except OSError as error:
|
|
190
|
+
print(
|
|
191
|
+
SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE.format(
|
|
192
|
+
path=profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME, error=error
|
|
193
|
+
),
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
159
198
|
def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
160
199
|
"""Name the account list, in try order, without duplicates.
|
|
161
200
|
|
|
162
201
|
::
|
|
163
202
|
|
|
164
|
-
CODEX_ACCOUNT_PROFILES="alpha, beta,alpha" -> ("alpha", "beta")
|
|
203
|
+
CODEX_ACCOUNT_PROFILES="alpha, beta,alpha" -> ("alpha", "beta"), saved to the file
|
|
165
204
|
unset, account-launchers.json ["gamma"] -> ("gamma",)
|
|
166
205
|
unset, no file -> ()
|
|
167
206
|
|
|
@@ -170,11 +209,16 @@ def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
|
170
209
|
|
|
171
210
|
Returns:
|
|
172
211
|
The environment's names when it names any, else the saved file's names.
|
|
212
|
+
Environment names that differ from the file are saved to it.
|
|
173
213
|
|
|
174
214
|
Raises:
|
|
175
215
|
CodexAccountNameError: When the environment or the file names an invalid account.
|
|
176
216
|
"""
|
|
177
|
-
|
|
217
|
+
all_environment_names = _environment_roster()
|
|
218
|
+
if all_environment_names:
|
|
219
|
+
_save_environment_roster(profiles_root, all_environment_names)
|
|
220
|
+
return all_environment_names
|
|
221
|
+
return _saved_roster_file_names(profiles_root)
|
|
178
222
|
|
|
179
223
|
|
|
180
224
|
def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
@@ -192,15 +236,48 @@ def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
|
192
236
|
def save_codex_account_names(profiles_root: Path, all_names: Sequence[str]) -> None:
|
|
193
237
|
"""Write the account list as a JSON list under the profiles root.
|
|
194
238
|
|
|
239
|
+
The list goes to a staged file first, and ``os.replace`` moves it over the
|
|
240
|
+
saved file in one step, so a concurrent reader sees the old list or the new one.
|
|
241
|
+
|
|
195
242
|
Args:
|
|
196
243
|
profiles_root: The directory that holds every account's Codex home.
|
|
197
244
|
all_names: The account names in try order.
|
|
198
245
|
"""
|
|
199
246
|
profiles_root.mkdir(parents=True, exist_ok=True)
|
|
200
247
|
roster_text = json.dumps(list(all_names), indent=ROSTER_JSON_INDENT) + "\n"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
248
|
+
roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
|
|
249
|
+
staged_path = roster_path.with_name(f"{roster_path.name}.{os.getpid()}.tmp")
|
|
250
|
+
try:
|
|
251
|
+
staged_path.write_bytes(roster_text.encode(TEXT_ENCODING))
|
|
252
|
+
_replace_with_retries(staged_path, roster_path)
|
|
253
|
+
except OSError:
|
|
254
|
+
staged_path.unlink(missing_ok=True)
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _replace_with_retries(staged_path: Path, roster_path: Path) -> None:
|
|
259
|
+
"""Replace the saved roster, retrying while a Windows reader holds it open.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
staged_path: The fully written new roster file.
|
|
263
|
+
roster_path: The saved roster file to replace.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
PermissionError: When the saved file stays locked through every attempt.
|
|
267
|
+
"""
|
|
268
|
+
for _ in range(ROSTER_REPLACE_ATTEMPTS - 1):
|
|
269
|
+
if _replaced(staged_path, roster_path):
|
|
270
|
+
return
|
|
271
|
+
time.sleep(ROSTER_REPLACE_RETRY_SECONDS)
|
|
272
|
+
os.replace(staged_path, roster_path)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _replaced(staged_path: Path, roster_path: Path) -> bool:
|
|
276
|
+
try:
|
|
277
|
+
os.replace(staged_path, roster_path)
|
|
278
|
+
except PermissionError:
|
|
279
|
+
return False
|
|
280
|
+
return True
|
|
204
281
|
|
|
205
282
|
|
|
206
283
|
def _sync_account(
|
|
@@ -403,9 +480,24 @@ def _run_setup(arguments: argparse.Namespace) -> int:
|
|
|
403
480
|
now=now,
|
|
404
481
|
)
|
|
405
482
|
print(json.dumps({JSON_INSTALLED_KEY: all_installed, JSON_RETIRED_KEY: all_retired}))
|
|
483
|
+
_warn_when_the_environment_replaces(all_names)
|
|
406
484
|
return 0
|
|
407
485
|
|
|
408
486
|
|
|
487
|
+
def _warn_when_the_environment_replaces(all_setup_names: tuple[str, ...]) -> None:
|
|
488
|
+
try:
|
|
489
|
+
all_environment_names = _environment_roster()
|
|
490
|
+
except CodexAccountNameError:
|
|
491
|
+
return
|
|
492
|
+
if all_environment_names and all_environment_names != all_setup_names:
|
|
493
|
+
print(
|
|
494
|
+
SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE.format(
|
|
495
|
+
names=SETUP_NAMES_SEPARATOR.join(all_environment_names)
|
|
496
|
+
),
|
|
497
|
+
file=sys.stderr,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
409
501
|
def _add_main_home_argument(command_parser: argparse.ArgumentParser) -> None:
|
|
410
502
|
command_parser.add_argument(
|
|
411
503
|
"--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
|
|
@@ -39,6 +39,23 @@ INVALID_ACCOUNT_ROSTER_TEMPLATE: str = "{source} names {name!r}: {reason}"
|
|
|
39
39
|
ROSTER_NOT_A_LIST_TEMPLATE: str = "{source} must hold a JSON list of account names"
|
|
40
40
|
"""Error when the roster JSON is anything other than a list of names."""
|
|
41
41
|
|
|
42
|
+
SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE: str = (
|
|
43
|
+
"warning: cannot save the CODEX_ACCOUNT_PROFILES roster to {path}: {error}"
|
|
44
|
+
)
|
|
45
|
+
"""Warning when the environment roster cannot be copied into the saved file."""
|
|
46
|
+
|
|
47
|
+
ROSTER_REPLACE_ATTEMPTS: int = 5
|
|
48
|
+
"""Tries at replacing the saved roster while a Windows reader holds it open."""
|
|
49
|
+
|
|
50
|
+
ROSTER_REPLACE_RETRY_SECONDS: float = 0.05
|
|
51
|
+
"""Pause between tries at replacing the saved roster."""
|
|
52
|
+
|
|
53
|
+
SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE: str = (
|
|
54
|
+
"warning: CODEX_ACCOUNT_PROFILES names {names}; the next broker, sync or install run"
|
|
55
|
+
" saves that list over this setup list"
|
|
56
|
+
)
|
|
57
|
+
"""Warning when setup saves a list that the environment roster will replace."""
|
|
58
|
+
|
|
42
59
|
ROSTER_JSON_INDENT: int = 2
|
|
43
60
|
"""Indent of the saved roster JSON, one name per line."""
|
|
44
61
|
|
|
@@ -175,8 +175,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
175
175
|
asks the model to check a hedged claim, and the call it reads stays in place.
|
|
176
176
|
|
|
177
177
|
The session title gates keep the session title in its status format. One
|
|
178
|
-
denies a malformed title, and the other asks for a title
|
|
179
|
-
|
|
178
|
+
denies a malformed title, and the other asks for a new title when the
|
|
179
|
+
shown status stops fitting. They decide the shape of a title and no code
|
|
180
|
+
or safety policy.
|
|
180
181
|
|
|
181
182
|
The artifact page gate denies a page publish whose text is unreadable in
|
|
182
183
|
dark mode. It decides how a page renders and no code or safety policy.
|
|
@@ -193,10 +194,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
193
194
|
names open review findings. It reads only the wording of a chat card and
|
|
194
195
|
decides no code or safety policy; the merge review gate holds the merge.
|
|
195
196
|
|
|
196
|
-
The open tasks Stop gate holds the end of a turn
|
|
197
|
-
list
|
|
198
|
-
|
|
199
|
-
safety policy.
|
|
197
|
+
The open tasks Stop gate holds the end of a turn once when the turn worked
|
|
198
|
+
on an ask and made no task list call. It decides when a turn may end and
|
|
199
|
+
no code or safety policy.
|
|
200
200
|
|
|
201
201
|
The owned pull request Stop gate holds the end of a turn while a pull
|
|
202
202
|
request the session opened on an owned repository is still open and the
|
|
@@ -850,6 +850,18 @@ def test_should_use_default_codex_home_without_roster(
|
|
|
850
850
|
assert "no roster" in decision.reason
|
|
851
851
|
|
|
852
852
|
|
|
853
|
+
def test_should_keep_the_codex_roster_for_a_process_without_the_roster_variable(
|
|
854
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
855
|
+
) -> None:
|
|
856
|
+
monkeypatch.setattr(account_broker.support.codex_account_choice, "default_profiles_root", lambda: tmp_path / "profiles")
|
|
857
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "team,personal")
|
|
858
|
+
assert [each_account.name for each_account in account_broker.load_codex_accounts()] == ["team", "personal"]
|
|
859
|
+
|
|
860
|
+
monkeypatch.delenv("CODEX_ACCOUNT_PROFILES")
|
|
861
|
+
|
|
862
|
+
assert [each_account.name for each_account in account_broker.load_codex_accounts()] == ["team", "personal"]
|
|
863
|
+
|
|
864
|
+
|
|
853
865
|
def test_should_reuse_meter_cache_for_60_seconds() -> None:
|
|
854
866
|
account = _account("cached")
|
|
855
867
|
calls: list[str] = []
|
|
@@ -144,6 +144,79 @@ class TestAccountRoster:
|
|
|
144
144
|
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", " beta , alpha,, ")
|
|
145
145
|
assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
|
|
146
146
|
|
|
147
|
+
def should_save_the_environment_roster_for_a_process_without_it(
|
|
148
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
149
|
+
) -> None:
|
|
150
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
|
|
151
|
+
assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
|
|
152
|
+
monkeypatch.delenv("CODEX_ACCOUNT_PROFILES")
|
|
153
|
+
assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize("saved_bytes", [b'["alpha",', b"\xff\xfe\x00"])
|
|
156
|
+
def should_replace_an_unreadable_saved_file_with_the_environment_roster(
|
|
157
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, saved_bytes: bytes
|
|
158
|
+
) -> None:
|
|
159
|
+
(tmp_path / "account-launchers.json").write_bytes(saved_bytes)
|
|
160
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
|
|
161
|
+
assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
|
|
162
|
+
assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
|
|
163
|
+
|
|
164
|
+
def should_keep_the_environment_roster_when_the_saved_file_cannot_be_read(
|
|
165
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
166
|
+
) -> None:
|
|
167
|
+
def refuse_read(_: Path) -> tuple[str, ...]:
|
|
168
|
+
raise PermissionError("locked")
|
|
169
|
+
|
|
170
|
+
monkeypatch.setattr(choice, "_saved_roster_file_names", refuse_read)
|
|
171
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
|
|
172
|
+
assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
|
|
173
|
+
|
|
174
|
+
def should_remove_the_staged_roster_when_the_write_fails(
|
|
175
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
176
|
+
) -> None:
|
|
177
|
+
original_write_bytes = Path.write_bytes
|
|
178
|
+
|
|
179
|
+
def fail_after_partial_write(path: Path, data: bytes) -> int:
|
|
180
|
+
original_write_bytes(path, data[:3])
|
|
181
|
+
raise OSError("disk full")
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr(Path, "write_bytes", fail_after_partial_write)
|
|
184
|
+
with pytest.raises(OSError, match="disk full"):
|
|
185
|
+
choice.save_codex_account_names(tmp_path, ("alpha",))
|
|
186
|
+
assert list(tmp_path.iterdir()) == []
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("document", [["gamma"], ["alpha", "bad name"], {"alpha": 1}])
|
|
189
|
+
def should_replace_a_saved_file_that_differs_from_the_environment(
|
|
190
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, document: object
|
|
191
|
+
) -> None:
|
|
192
|
+
save_roster(tmp_path, document)
|
|
193
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
|
|
194
|
+
choice.saved_codex_account_names(tmp_path)
|
|
195
|
+
assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
|
|
196
|
+
|
|
197
|
+
def should_leave_a_saved_file_that_matches_the_environment_untouched(
|
|
198
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
199
|
+
) -> None:
|
|
200
|
+
save_roster(tmp_path, ["beta", "alpha"])
|
|
201
|
+
saved_bytes = (tmp_path / "account-launchers.json").read_bytes()
|
|
202
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
|
|
203
|
+
choice.saved_codex_account_names(tmp_path)
|
|
204
|
+
assert (tmp_path / "account-launchers.json").read_bytes() == saved_bytes
|
|
205
|
+
|
|
206
|
+
def should_keep_the_environment_roster_when_the_file_cannot_be_written(
|
|
207
|
+
self,
|
|
208
|
+
tmp_path: Path,
|
|
209
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
210
|
+
capsys: pytest.CaptureFixture[str],
|
|
211
|
+
) -> None:
|
|
212
|
+
def refuse_save(*_: object) -> None:
|
|
213
|
+
raise PermissionError("read-only")
|
|
214
|
+
|
|
215
|
+
monkeypatch.setattr(choice, "save_codex_account_names", refuse_save)
|
|
216
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
|
|
217
|
+
assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
|
|
218
|
+
assert "cannot save the CODEX_ACCOUNT_PROFILES roster" in capsys.readouterr().err
|
|
219
|
+
|
|
147
220
|
def should_read_the_saved_file_when_the_environment_is_unset(
|
|
148
221
|
self, tmp_path: Path
|
|
149
222
|
) -> None:
|
|
@@ -190,6 +263,37 @@ class TestAccountRoster:
|
|
|
190
263
|
b'[\n "alpha",\n "beta"\n]\n'
|
|
191
264
|
)
|
|
192
265
|
assert choice.saved_codex_account_names(profiles_root) == ("alpha", "beta")
|
|
266
|
+
assert [each.name for each in profiles_root.iterdir()] == ["account-launchers.json"]
|
|
267
|
+
|
|
268
|
+
def should_retry_a_replace_while_a_reader_holds_the_roster(
|
|
269
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
270
|
+
) -> None:
|
|
271
|
+
original_replace = os.replace
|
|
272
|
+
all_attempts: list[object] = []
|
|
273
|
+
|
|
274
|
+
def refuse_first_replace(source: Path, target: Path) -> None:
|
|
275
|
+
all_attempts.append(source)
|
|
276
|
+
if len(all_attempts) == 1:
|
|
277
|
+
raise PermissionError("in use")
|
|
278
|
+
original_replace(source, target)
|
|
279
|
+
|
|
280
|
+
monkeypatch.setattr(choice.os, "replace", refuse_first_replace)
|
|
281
|
+
monkeypatch.setattr(choice.time, "sleep", lambda _: None)
|
|
282
|
+
choice.save_codex_account_names(tmp_path, ("alpha",))
|
|
283
|
+
assert len(all_attempts) == 2
|
|
284
|
+
assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
|
|
285
|
+
|
|
286
|
+
def should_remove_the_staged_roster_when_the_replace_fails(
|
|
287
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
288
|
+
) -> None:
|
|
289
|
+
def refuse_replace(*_: object) -> None:
|
|
290
|
+
raise PermissionError("in use")
|
|
291
|
+
|
|
292
|
+
monkeypatch.setattr(choice.os, "replace", refuse_replace)
|
|
293
|
+
monkeypatch.setattr(choice.time, "sleep", lambda _: None)
|
|
294
|
+
with pytest.raises(PermissionError):
|
|
295
|
+
choice.save_codex_account_names(tmp_path, ("alpha",))
|
|
296
|
+
assert list(tmp_path.iterdir()) == []
|
|
193
297
|
|
|
194
298
|
def should_fall_back_to_the_four_fixed_accounts_without_a_roster(
|
|
195
299
|
self, tmp_path: Path
|
|
@@ -308,6 +412,23 @@ class TestSetupRoster:
|
|
|
308
412
|
"profile name must use letters, digits, hyphens, or underscores",
|
|
309
413
|
]
|
|
310
414
|
|
|
415
|
+
@pytest.mark.parametrize(("environment_roster", "is_warned"), [("alpha,beta", True), ("alpha", False), ("", False), ("bad name", False)])
|
|
416
|
+
def should_warn_when_the_environment_roster_will_replace_the_setup_list(
|
|
417
|
+
self,
|
|
418
|
+
tmp_path: Path,
|
|
419
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
420
|
+
capsys: pytest.CaptureFixture[str],
|
|
421
|
+
environment_roster: str,
|
|
422
|
+
is_warned: bool,
|
|
423
|
+
) -> None:
|
|
424
|
+
monkeypatch.setattr(choice, "update_codex_account_roster", lambda **_: (("alpha",), {}))
|
|
425
|
+
monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", environment_roster)
|
|
426
|
+
all_arguments = ["--profiles-root", str(tmp_path / "profiles"), "setup"]
|
|
427
|
+
main_home = build_codex_main_home(tmp_path)
|
|
428
|
+
assert choice.main([*all_arguments, "--main-home", str(main_home), "--launcher-directory", str(tmp_path / "bin")]) == 0
|
|
429
|
+
assert ("CODEX_ACCOUNT_PROFILES names alpha" in capsys.readouterr().err) is is_warned
|
|
430
|
+
assert (tmp_path / "bin" / "codex-alpha.cmd").is_file()
|
|
431
|
+
|
|
311
432
|
def should_finish_at_the_end_of_input(self, tmp_path: Path) -> None:
|
|
312
433
|
all_names, _ = choice.update_codex_account_roster(
|
|
313
434
|
profiles_root=tmp_path,
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""UserPromptSubmit hook that records each prompt for the open tasks Stop gate.
|
|
3
|
-
|
|
4
|
-
A prompt that asks for work becomes ``#ask-N`` in
|
|
5
|
-
``~/.claude/open-task-asks/<session id>.json``. The gate then holds the turn
|
|
6
|
-
until a reply closes it with ``Done: #ask-N``, or with
|
|
7
|
-
``Dropped: #ask-N - "<the user's words>"`` quoting a later prompt. The hook
|
|
8
|
-
prints nothing and always exits 0.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
-
if hooks_root_directory not in sys.path:
|
|
18
|
-
sys.path.insert(0, hooks_root_directory)
|
|
19
|
-
|
|
20
|
-
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
21
|
-
ALLOW_EXIT_CODE,
|
|
22
|
-
PROMPT_KEY,
|
|
23
|
-
SESSION_ID_KEY,
|
|
24
|
-
)
|
|
25
|
-
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
26
|
-
from open_task_asks import record_prompt
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def main() -> int:
|
|
30
|
-
hook_input = read_hook_input_dictionary_from_stdin()
|
|
31
|
-
prompt_value = hook_input.get(PROMPT_KEY) if hook_input is not None else None
|
|
32
|
-
if hook_input is not None and isinstance(prompt_value, str):
|
|
33
|
-
record_prompt(hook_input.get(SESSION_ID_KEY), prompt_value)
|
|
34
|
-
return ALLOW_EXIT_CODE
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if __name__ == "__main__":
|
|
38
|
-
sys.exit(main())
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
RECORDER_PATH = Path(__file__).resolve().parent / "open_task_ask_recorder.py"
|
|
7
|
-
SESSION_ID = "s-123"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def submit_prompt(home_directory: Path, prompt_text: str) -> subprocess.CompletedProcess[str]:
|
|
11
|
-
hook_input = {
|
|
12
|
-
"hook_event_name": "UserPromptSubmit",
|
|
13
|
-
"session_id": SESSION_ID,
|
|
14
|
-
"prompt": prompt_text,
|
|
15
|
-
}
|
|
16
|
-
return subprocess.run(
|
|
17
|
-
[sys.executable, str(RECORDER_PATH)],
|
|
18
|
-
input=json.dumps(hook_input),
|
|
19
|
-
capture_output=True,
|
|
20
|
-
text=True,
|
|
21
|
-
env={
|
|
22
|
-
"HOME": str(home_directory),
|
|
23
|
-
"USERPROFILE": str(home_directory),
|
|
24
|
-
"PATH": "",
|
|
25
|
-
},
|
|
26
|
-
check=False,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def recorded_ask_ids(home_directory: Path) -> list[str]:
|
|
31
|
-
asks_path = home_directory / ".claude" / "open-task-asks" / f"{SESSION_ID}.json"
|
|
32
|
-
all_prompts = json.loads(asks_path.read_text(encoding="utf-8"))["prompts"]
|
|
33
|
-
return [each_prompt["ask_id"] for each_prompt in all_prompts if each_prompt["ask_id"]]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_should_record_each_ask_outside_the_task_store_and_stay_silent(
|
|
37
|
-
tmp_path: Path,
|
|
38
|
-
) -> None:
|
|
39
|
-
first_process = submit_prompt(tmp_path, "Export the OneUI8 and OneUI9 APKs and submit them")
|
|
40
|
-
submit_prompt(tmp_path, "did the export fail?")
|
|
41
|
-
submit_prompt(tmp_path, "<task-notification>\n<task-id>b1</task-id>\n</task-notification>")
|
|
42
|
-
submit_prompt(tmp_path, "can you check the release dates in neon?")
|
|
43
|
-
assert (first_process.returncode, first_process.stdout) == (0, "")
|
|
44
|
-
assert recorded_ask_ids(tmp_path) == ["ask-1", "ask-2"]
|
|
45
|
-
assert not (tmp_path / ".claude" / "tasks").exists()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_should_allow_malformed_input(tmp_path: Path) -> None:
|
|
49
|
-
completed_process = subprocess.run(
|
|
50
|
-
[sys.executable, str(RECORDER_PATH)],
|
|
51
|
-
input="not json",
|
|
52
|
-
capture_output=True,
|
|
53
|
-
text=True,
|
|
54
|
-
env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
|
|
55
|
-
check=False,
|
|
56
|
-
)
|
|
57
|
-
assert (completed_process.returncode, completed_process.stdout) == (0, "")
|