claude-dev-env 8.98.10 → 8.99.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/.agents/agents/reference/pr-description-illustrative-voice.md +2 -2
- package/.agents/agents/reference/pr-description-verification.md +1 -1
- package/.agents/agents/test_pr_description_writer_style.py +2 -2
- package/.agents/skills/pull-request/SKILL.md +4 -4
- package/.agents/skills/pull-request/reference/body-sections.md +17 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +5 -2
- package/.agents/skills/pull-request/scripts/pull_request.py +8 -8
- package/.agents/skills/pull-request/scripts/test_pull_request.py +17 -0
- package/bin/install.settings-defaults.test.mjs +6 -1
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +11 -0
- package/package.json +1 -1
- package/scripts/codex_account_choice.py +98 -6
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +17 -0
- package/scripts/durable_post_lint.py +1 -3
- package/scripts/durable_post_lint_config/config/constants.py +3 -5
- package/scripts/durable_post_lint_config/config/test_constants.py +2 -2
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_codex_account_choice.py +121 -0
- package/scripts/test_durable_post_lint.py +9 -5
- package/settings.json +5 -0
|
@@ -45,6 +45,6 @@ The person running the loop asked for a full look, so every pass gets a full loo
|
|
|
45
45
|
- Prefer Before / After when the change is about how something is asked, found, checked, saved, retried, or stopped.
|
|
46
46
|
- Use picture words: hand it, open it, find it, check it, save it, skip it, stop, call by name.
|
|
47
47
|
- Prefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”
|
|
48
|
-
- Keep `
|
|
49
|
-
- Put proof only in `
|
|
48
|
+
- Keep `What this does` short and concrete.
|
|
49
|
+
- Put proof only in `Proof in practice`.
|
|
50
50
|
- Explain jargon immediately or replace it.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Use this only when the PR writer needs a concrete verification sample.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Proof in practice should lead with something a reviewer can open, see, click, compare, or try.
|
|
6
6
|
|
|
7
7
|
Good:
|
|
8
8
|
|
|
@@ -92,10 +92,9 @@ Check each behavior claim against the final diff and verification evidence.
|
|
|
92
92
|
Preserve whether a rule is added, removed, or narrowed, and distinguish tests
|
|
93
93
|
added from tests run. Refresh these claims after a rebase or correction.
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
results, and material limits under `## Verification`.
|
|
95
|
+
Write the body in the section order `reference/body-sections.md` gives: What
|
|
96
|
+
this does, What changed, Existing work, Proof in practice, Eval for a `feat`
|
|
97
|
+
title, Risk, How to revert. Include only claims supported by the final diff.
|
|
99
98
|
|
|
100
99
|
Keep substantive review and test evidence. Put extensive logs, raw diffs, token
|
|
101
100
|
counts, and agent transcripts in an existing linked artifact or a closed
|
|
@@ -189,6 +188,7 @@ Read `reference/examples.md` for a create, a rejected comment, and a recovery.
|
|
|
189
188
|
| `reference/publication-tasks.md` | Ordered tasks for every publication gate |
|
|
190
189
|
| `reference/legacy-author-recovery.md` | Recovery of one selected legacy author record |
|
|
191
190
|
| `reference/examples.md` | Worked create, rejected comment, and recovery examples |
|
|
191
|
+
| `reference/body-sections.md` | Pull request body sections, their order, and what each holds |
|
|
192
192
|
| `scripts/pull_request.py` | Command for author selection and create, edit, comment, and review actions |
|
|
193
193
|
| `scripts/github_pr_command_constants/` | Constants for pull request actions, recovery, exit codes, and generic messages |
|
|
194
194
|
| `scripts/test_pull_request.py` | Tests for `pull_request.py` |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Pull request body sections
|
|
2
|
+
|
|
3
|
+
A reviewer reads the body in place of the code. The body says what the change does, how it was tested, what could break, and how to undo it. The order follows the Claude Code team's [code review article](https://claude.dev/blog/solving-code-review-at-anthropic/), whose template "asks how the change was tested and how to undo it". The opening sections follow the team's public pull requests, such as [claude-code #69470](https://github.com/anthropics/claude-code/pull/69470), which opens with What this does, Why now and What changed.
|
|
4
|
+
|
|
5
|
+
| Section | Holds |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `## What this does` | Two or three plain sentences: the behavior before, the behavior after, and who notices. Written for a reviewer who has not read the worker's conversation. |
|
|
8
|
+
| `## What changed` | One bullet per area of the diff, in plain words. |
|
|
9
|
+
| `## Existing work` | What already did part of the job, with a link or file and line, and what this change adds. Or the places searched. |
|
|
10
|
+
| `## Proof in practice` | Each command in backticks, the output without the change and with it, and anything still unproven. |
|
|
11
|
+
| `## Eval` | Only for a `feat` title. The eval's cases and grader, the command, and its result. |
|
|
12
|
+
| `## Risk` | What could break, for whom, and how anyone would notice. `Low` and one reason for a doc or test change. |
|
|
13
|
+
| `## How to revert` | The exact undo step, and any data or state a plain revert leaves behind. |
|
|
14
|
+
|
|
15
|
+
A repository's own sections, such as a request record or a session link, follow these. Optional `## Why now` and `## After merge` sections go after What this does and after How to revert.
|
|
16
|
+
|
|
17
|
+
`pull_request.py template` writes the headings in this order into a new body, and appends each missing heading to the end of a body that already has some. `durable_post_lint.py` reports a create body missing What this does, Risk, or How to revert, and the proof gate holds one missing Existing work, Proof in practice, or Eval for a `feat` title.
|
|
@@ -9,11 +9,14 @@ ACTION_REVIEW = "review"
|
|
|
9
9
|
ACTION_TEMPLATE = "template"
|
|
10
10
|
|
|
11
11
|
ALL_BODY_TEMPLATE_HEADINGS: tuple[str, ...] = (
|
|
12
|
-
"
|
|
12
|
+
"What this does",
|
|
13
|
+
"What changed",
|
|
13
14
|
"Existing work",
|
|
14
15
|
"Proof in practice",
|
|
15
|
-
"
|
|
16
|
+
"Risk",
|
|
17
|
+
"How to revert",
|
|
16
18
|
)
|
|
19
|
+
FEATURE_BODY_TEMPLATE_HEADING_FOLLOWS = "Proof in practice"
|
|
17
20
|
FEATURE_BODY_TEMPLATE_HEADING = "Eval"
|
|
18
21
|
FEATURE_TITLE_PATTERN: re.Pattern[str] = re.compile(r"^\s*feat(\([^)]*\))?!?:")
|
|
19
22
|
BODY_TEMPLATE_HEADING_FORMAT = "## {heading}"
|
|
@@ -26,6 +26,7 @@ from github_pr_command_constants.config.constants import (
|
|
|
26
26
|
BODY_TEMPLATE_HEADING_FORMAT,
|
|
27
27
|
BODY_TEMPLATE_LINE_END,
|
|
28
28
|
FEATURE_BODY_TEMPLATE_HEADING,
|
|
29
|
+
FEATURE_BODY_TEMPLATE_HEADING_FOLLOWS,
|
|
29
30
|
FEATURE_TITLE_PATTERN,
|
|
30
31
|
ALL_GITHUB_AUTHORIZATION_ENVIRONMENT_KEYS,
|
|
31
32
|
ALL_LINTER_ACTIONS_BY_COMMAND,
|
|
@@ -77,13 +78,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
77
78
|
|
|
78
79
|
|
|
79
80
|
def _template_headings(title: str) -> list[str]:
|
|
80
|
-
|
|
81
|
-
BODY_TEMPLATE_HEADING_FORMAT.format(heading=each_heading)
|
|
82
|
-
for each_heading in ALL_BODY_TEMPLATE_HEADINGS
|
|
83
|
-
]
|
|
81
|
+
all_heading_names = list(ALL_BODY_TEMPLATE_HEADINGS)
|
|
84
82
|
if FEATURE_TITLE_PATTERN.match(title):
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
eval_position = all_heading_names.index(FEATURE_BODY_TEMPLATE_HEADING_FOLLOWS) + 1
|
|
84
|
+
all_heading_names.insert(eval_position, FEATURE_BODY_TEMPLATE_HEADING)
|
|
85
|
+
return [BODY_TEMPLATE_HEADING_FORMAT.format(heading=each_heading) for each_heading in all_heading_names]
|
|
87
86
|
|
|
88
87
|
|
|
89
88
|
def write_body_template(body_file: Path, title: str) -> list[str]:
|
|
@@ -91,8 +90,9 @@ def write_body_template(body_file: Path, title: str) -> list[str]:
|
|
|
91
90
|
|
|
92
91
|
::
|
|
93
92
|
|
|
94
|
-
new file, title "fix: x" -> ["##
|
|
95
|
-
|
|
93
|
+
new file, title "fix: x" -> ["## What this does", "## What changed", "## Existing work",
|
|
94
|
+
"## Proof in practice", "## Risk", "## How to revert"]
|
|
95
|
+
new file, title "feat: x" -> the same with "## Eval" after "## Proof in practice"
|
|
96
96
|
file already has them all -> [] and the file is unchanged
|
|
97
97
|
|
|
98
98
|
Args:
|
|
@@ -544,3 +544,20 @@ def should_keep_written_text_and_add_only_missing_headings(tmp_path: Path) -> No
|
|
|
544
544
|
assert first_text.startswith("## Why\nThe gate bounced twice.\n")
|
|
545
545
|
assert first_text.count("## Why") == 1
|
|
546
546
|
assert body_path.read_text(encoding="utf-8") == first_text
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def should_write_the_reader_headings_in_order_with_eval_after_the_proof(tmp_path: Path) -> None:
|
|
550
|
+
body_path = tmp_path / BODY_FILENAME
|
|
551
|
+
_run_template(body_path, "feat(hooks): add the gate")
|
|
552
|
+
all_heading_lines = [
|
|
553
|
+
each_line for each_line in body_path.read_text(encoding="utf-8").splitlines() if each_line.startswith("## ")
|
|
554
|
+
]
|
|
555
|
+
assert all_heading_lines == [
|
|
556
|
+
"## What this does",
|
|
557
|
+
"## What changed",
|
|
558
|
+
"## Existing work",
|
|
559
|
+
"## Proof in practice",
|
|
560
|
+
"## Eval",
|
|
561
|
+
"## Risk",
|
|
562
|
+
"## How to revert",
|
|
563
|
+
]
|
|
@@ -35,6 +35,11 @@ const INSTALL_ENTRY = join(PACKAGE_ROOT, 'bin', 'install.mjs');
|
|
|
35
35
|
const EXPECTED_DENY_ENTRIES = [];
|
|
36
36
|
const EXPECTED_ALLOW_ENTRIES = [
|
|
37
37
|
'WebFetch(domain:docs.github.com)',
|
|
38
|
+
'WebFetch(domain:claude.com)',
|
|
39
|
+
'WebFetch(domain:*.claude.com)',
|
|
40
|
+
'WebFetch(domain:claude.dev)',
|
|
41
|
+
'WebFetch(domain:*.claude.dev)',
|
|
42
|
+
'WebFetch(domain:www.anthropic.com)',
|
|
38
43
|
'Read(~/.claude/docs/**)',
|
|
39
44
|
];
|
|
40
45
|
const PROFILE_ALLOW_ENTRIES = DECLARED_PROFILE_SETTINGS
|
|
@@ -76,7 +81,7 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
|
|
|
76
81
|
});
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
test('package settings.json publishes the
|
|
84
|
+
test('package settings.json publishes the docs fetch allows and no managed deny entries', () => {
|
|
80
85
|
assert.deepEqual(packageManagedPermissions(), {
|
|
81
86
|
allow: EXPECTED_ALLOW_ENTRIES,
|
|
82
87
|
deny: EXPECTED_DENY_ENTRIES,
|
package/docs/account-broker.md
CHANGED
|
@@ -6,7 +6,7 @@ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chai
|
|
|
6
6
|
|
|
7
7
|
The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
|
|
8
8
|
|
|
9
|
-
Codex uses its configured account list. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
|
|
9
|
+
Codex uses its configured account list. A broker run that reads `CODEX_ACCOUNT_PROFILES` saves that list to `account-launchers.json`, so a later run without the variable reads the same roster; [Codex accounts](codex-accounts.md) has the details. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
|
|
10
10
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
package/docs/codex-accounts.md
CHANGED
|
@@ -22,6 +22,11 @@ The account list names the accounts and their order. It comes from the
|
|
|
22
22
|
in `account-launchers.json` under the profiles root. When neither exists, the
|
|
23
23
|
accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
|
|
24
24
|
|
|
25
|
+
Each read of the variable's list saves it to `account-launchers.json` when the
|
|
26
|
+
file holds a different list. A service, runner, or other process started
|
|
27
|
+
without the variable then reads the same roster from the file. When the file
|
|
28
|
+
cannot be written, the read prints a warning and uses the variable's list.
|
|
29
|
+
|
|
25
30
|
The broker's `choose` and `check` commands and the setup commands use that list.
|
|
26
31
|
Each account's home is `~/.codex-profiles/<name>`, or
|
|
27
32
|
`<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
|
|
@@ -84,6 +89,12 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
|
|
|
84
89
|
again restores it. A blank first line empties the roster and moves every launcher
|
|
85
90
|
aside.
|
|
86
91
|
|
|
92
|
+
While `CODEX_ACCOUNT_PROFILES` is set, the variable's list stays the roster. The
|
|
93
|
+
next broker, sync, or install run saves it over the setup list, and setup prints
|
|
94
|
+
a warning when the two differ. Change or clear the variable to change the
|
|
95
|
+
roster. Two processes with different values of the variable each save their own
|
|
96
|
+
list, so a process without the variable reads whichever list was saved last.
|
|
97
|
+
|
|
87
98
|
## Which account a job uses
|
|
88
99
|
|
|
89
100
|
Room is the least percent left across an account's windows, such as the 5-hour window and the week.
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -25,7 +25,6 @@ from durable_post_lint_config.config.constants import (
|
|
|
25
25
|
ALL_BODY_REQUIRED_ACTIONS,
|
|
26
26
|
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
|
|
27
27
|
ALL_POST_ACTIONS,
|
|
28
|
-
ALL_PR_DESCRIPTION_ACTIONS,
|
|
29
28
|
ALL_RELEASE_BODY_MARKERS,
|
|
30
29
|
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
|
|
31
30
|
ALL_TITLE_ACTIONS,
|
|
@@ -266,9 +265,8 @@ def _body_findings(
|
|
|
266
265
|
if _is_release_automation_branch(head_branch):
|
|
267
266
|
all_findings.extend(_release_body_findings(body_text))
|
|
268
267
|
else:
|
|
269
|
-
if action in ALL_PR_DESCRIPTION_ACTIONS:
|
|
270
|
-
all_findings.extend(_description_heading_findings(body_text))
|
|
271
268
|
if action == ACTION_PR_CREATE:
|
|
269
|
+
all_findings.extend(_description_heading_findings(body_text))
|
|
272
270
|
all_findings.extend(_pull_request_proof_findings(title, body_text))
|
|
273
271
|
if action == ACTION_GITHUB_MCP_COMMENT:
|
|
274
272
|
all_findings.extend(_comment_length_findings(body_text))
|
|
@@ -37,9 +37,6 @@ ALL_BODY_REQUIRED_ACTIONS: frozenset[str] = frozenset(
|
|
|
37
37
|
ACTION_PR_REVIEW,
|
|
38
38
|
}
|
|
39
39
|
)
|
|
40
|
-
ALL_PR_DESCRIPTION_ACTIONS: frozenset[str] = frozenset(
|
|
41
|
-
{ACTION_PR_CREATE, ACTION_PR_EDIT}
|
|
42
|
-
)
|
|
43
40
|
ALL_TITLE_ACTIONS: frozenset[str] = frozenset({ACTION_PR_CREATE, ACTION_PR_EDIT})
|
|
44
41
|
ALL_CONVENTIONAL_TITLE_TYPES: tuple[str, ...] = (
|
|
45
42
|
"build",
|
|
@@ -58,8 +55,9 @@ CONVENTIONAL_TITLE_PATTERN: re.Pattern[str] = re.compile(
|
|
|
58
55
|
rf"^(?:{'|'.join(ALL_CONVENTIONAL_TITLE_TYPES)})(?:\([^)\r\n]+\))?!?: [^\s].*$"
|
|
59
56
|
)
|
|
60
57
|
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS: tuple[str, ...] = (
|
|
61
|
-
"
|
|
62
|
-
"
|
|
58
|
+
"What this does",
|
|
59
|
+
"Risk",
|
|
60
|
+
"How to revert",
|
|
63
61
|
)
|
|
64
62
|
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
65
63
|
".claude-profile-a/jobs/",
|
|
@@ -40,5 +40,5 @@ def should_name_the_branch_prefix_release_automation_pushes() -> None:
|
|
|
40
40
|
assert RELEASE_BRANCH_PREFIX == "release-please--branches--"
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def
|
|
44
|
-
assert ALL_REQUIRED_PR_DESCRIPTION_HEADINGS == ("
|
|
43
|
+
def should_require_the_reader_headings_a_reviewer_reads_in_place_of_the_code() -> None:
|
|
44
|
+
assert ALL_REQUIRED_PR_DESCRIPTION_HEADINGS == ("What this does", "Risk", "How to revert")
|
|
@@ -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,
|
|
@@ -18,9 +18,9 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
SCRIPT_PATH = Path(__file__).resolve().parent / "durable_post_lint.py"
|
|
21
|
-
VALID_PR_BODY = """##
|
|
21
|
+
VALID_PR_BODY = """## What this does
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
What this does text.
|
|
24
24
|
|
|
25
25
|
## Scope
|
|
26
26
|
|
|
@@ -34,9 +34,13 @@ Tradeoffs text.
|
|
|
34
34
|
|
|
35
35
|
Blast radius text.
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## Risk
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Risk text.
|
|
40
|
+
|
|
41
|
+
## How to revert
|
|
42
|
+
|
|
43
|
+
Revert text.
|
|
40
44
|
|
|
41
45
|
## Existing work
|
|
42
46
|
|
|
@@ -201,7 +205,7 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
|
|
|
201
205
|
]
|
|
202
206
|
|
|
203
207
|
|
|
204
|
-
def
|
|
208
|
+
def should_accept_an_edit_body_without_the_reader_headings() -> None:
|
|
205
209
|
all_findings = durable_post_lint.lint_durable_post(
|
|
206
210
|
action="pr-edit",
|
|
207
211
|
title="fix(cli): tighten input handling",
|
package/settings.json
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
"permissions": {
|
|
4
4
|
"allow": [
|
|
5
5
|
"WebFetch(domain:docs.github.com)",
|
|
6
|
+
"WebFetch(domain:claude.com)",
|
|
7
|
+
"WebFetch(domain:*.claude.com)",
|
|
8
|
+
"WebFetch(domain:claude.dev)",
|
|
9
|
+
"WebFetch(domain:*.claude.dev)",
|
|
10
|
+
"WebFetch(domain:www.anthropic.com)",
|
|
6
11
|
"Read(~/.claude/docs/**)"
|
|
7
12
|
]
|
|
8
13
|
},
|