claude-dev-env 8.68.3 → 8.69.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/skills/correction/SKILL.md +21 -7
- package/.agents/skills/correction/playbooks/fix.md +14 -0
- package/docs/codex-accounts.md +2 -1
- package/docs/second-claude-account.md +2 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +1 -0
- package/scripts/account_broker_support.py +16 -9
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +8 -1
- package/scripts/test_account_broker.py +25 -0
- package/scripts/test_account_broker_support.py +53 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: correction
|
|
3
|
-
description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake. Use when the user types /correction followed by what the agent got wrong.
|
|
4
|
-
argument-hint: "<what the agent got wrong, in the user's words>"
|
|
3
|
+
description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake, or land the fix in this session with the fix playbook. Use when the user types /correction followed by what the agent got wrong, or /correction fix to land the fix here.
|
|
4
|
+
argument-hint: "[fix] <what the agent got wrong, in the user's words>"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /correction
|
|
8
8
|
|
|
9
|
-
The user corrected an agent and wants the fix to stick. Write one brief
|
|
9
|
+
The user corrected an agent and wants the fix to stick. Write one brief that records the correction. By default the user pastes it into the place that turns corrections into lasting fixes. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself.
|
|
10
10
|
|
|
11
|
-
`$ARGUMENTS` is the user's statement of the correction. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
|
|
11
|
+
`$ARGUMENTS` is the user's statement of the correction, after a leading `fix` when one is present. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
|
|
12
12
|
|
|
13
|
-
Print the brief as one fenced `text` block
|
|
13
|
+
Print the brief as one fenced `text` block:
|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
-
Correction: <$ARGUMENTS
|
|
16
|
+
Correction: <$ARGUMENTS word for word, minus a leading fix>
|
|
17
17
|
Asked: <what the user asked for, quoted where a message carries it>
|
|
18
18
|
Agent did: <what the agent did, with the reply, file, or command that shows it>
|
|
19
19
|
Corrected to: <what the user wanted instead, or the fix already made>
|
|
@@ -22,4 +22,18 @@ Evidence: <file:line, log line, or quoted message that shows the miss>
|
|
|
22
22
|
Repeat: <yes, with the earlier time it happened | no | unknown>
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Keep each field to one or two lines. Leave out the layer and the fix design; the intake decides those.
|
|
25
|
+
Keep each field to one or two lines. Leave out the layer and the fix design; the intake or the fix playbook decides those.
|
|
26
|
+
|
|
27
|
+
## Playbooks
|
|
28
|
+
|
|
29
|
+
| Mode | What follows the brief |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Hand off, the default | Nothing. The brief is the whole reply. |
|
|
32
|
+
| `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
|
|
33
|
+
|
|
34
|
+
## Layout
|
|
35
|
+
|
|
36
|
+
| File | What it holds |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `SKILL.md` | The brief and the playbook index |
|
|
39
|
+
| `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Fix
|
|
2
|
+
|
|
3
|
+
Use this playbook when this session lands the fix for the correction itself. One correction gets one control and one pull request.
|
|
4
|
+
|
|
5
|
+
1. Write the brief from [`../SKILL.md`](../SKILL.md) first. It is the record every later step reads.
|
|
6
|
+
2. When pstack is loaded, invoke `/pstack:poteto-mode`, then `/correct`. Point `/correct` at the brief's mistake alone, and skip its sweep of the whole repository.
|
|
7
|
+
3. Read the correction instructions of the repository the mistake happened in. Open each `AGENTS.md` or `CLAUDE.md` index line that names corrections, and any intake note it points to. Where they name the target repository, the proof, or the run route, they win over this playbook.
|
|
8
|
+
4. Search for an existing control: a rule, hook, lint, test, or skill that already names this mistake. A control that exists and still let the mistake through makes this a repeat.
|
|
9
|
+
5. Choose the layer with [`correction-lens.md`](../../../../docs/rule-guides/correction-lens.md). When `Repeat` is `yes`, or step 4 found a control, go one layer above the one that failed.
|
|
10
|
+
6. Land the control in the repository whose code, CI, or agents it guards, per the guide's "Where the control lands" section. When that repository is not in this session, add it. When it cannot be added, print the brief, name the missing repository, and stop.
|
|
11
|
+
7. Prove the control. Show it fail on the mistake from the brief's `Evidence` line, then pass with the change, per [`falsify-before-green.md`](../../../../rules/falsify-before-green.md) and [`proof-before-pull-request.md`](../../../../rules/proof-before-pull-request.md). Without that proof, open no pull request.
|
|
12
|
+
8. Open the pull request through `pr-lifecycle`. Put the brief, the layer, and both proof results in the body.
|
|
13
|
+
|
|
14
|
+
**Reply:** the layer chosen, why each higher layer cannot hold the lesson, and the pull request link.
|
package/docs/codex-accounts.md
CHANGED
|
@@ -94,7 +94,8 @@ Room is the smaller of an account's two windows: the 5-hour window and the week.
|
|
|
94
94
|
| No account over 10%, one or more over 1% | `luna` on the account with the most room |
|
|
95
95
|
| An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
|
|
96
96
|
| No account over 1% | `wait`, naming the next reset when known |
|
|
97
|
-
| A meter
|
|
97
|
+
| A meter read fails | The broker reads it once more |
|
|
98
|
+
| A meter is unread after both reads | The account has `meters: null` and the failure text in `unread_reason`; a `wait` reason names the account and that text |
|
|
98
99
|
|
|
99
100
|
```
|
|
100
101
|
python packages/claude-dev-env/scripts/account_broker.py choose --product codex
|
|
@@ -55,6 +55,7 @@ python packages/claude-dev-env/scripts/account_broker.py choose --product claude
|
|
|
55
55
|
|
|
56
56
|
The JSON output has a `decision` object and an `accounts` list. Each account's
|
|
57
57
|
`meters` holds its remaining percent and reset time for the 5-hour window and
|
|
58
|
-
the week. An unreadable meter appears as `null
|
|
58
|
+
the week. An unreadable meter appears as `null`, and the account's
|
|
59
|
+
`unread_reason` holds the failure text when the reader raised one. A wait decision has
|
|
59
60
|
`action: "wait"` and a `resets_at` timestamp. The broker exits with code 3 on
|
|
60
61
|
wait. The worker writes the same reset time to its report.
|
package/package.json
CHANGED
|
@@ -125,6 +125,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
|
|
|
125
125
|
"is_main": each_reading.account.is_main,
|
|
126
126
|
"priority": each_reading.account.priority,
|
|
127
127
|
"meters": _meter_payload(each_reading.meters),
|
|
128
|
+
"unread_reason": each_reading.unread_reason,
|
|
128
129
|
}
|
|
129
130
|
for each_reading in all_readings
|
|
130
131
|
]
|
|
@@ -222,20 +222,24 @@ def read_claude_meters(account: Account) -> Meters | None:
|
|
|
222
222
|
)
|
|
223
223
|
|
|
224
224
|
|
|
225
|
-
def read_codex_account_meters(account: Account) -> Meters
|
|
225
|
+
def read_codex_account_meters(account: Account) -> Meters:
|
|
226
226
|
"""Probe one Codex account.
|
|
227
227
|
|
|
228
228
|
Args:
|
|
229
229
|
account: Account whose meter is read.
|
|
230
230
|
|
|
231
231
|
Returns:
|
|
232
|
-
Its meters,
|
|
232
|
+
Its meters, read again once when the first read fails.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
CodexMeterUnreadError: Both reads failed; the message is the second failure.
|
|
236
|
+
OSError: No Codex executable was found.
|
|
233
237
|
"""
|
|
238
|
+
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
234
239
|
try:
|
|
235
|
-
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
236
240
|
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
237
|
-
except
|
|
238
|
-
|
|
241
|
+
except codex_account_meters.CodexMeterUnreadError:
|
|
242
|
+
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
239
243
|
weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
|
|
240
244
|
short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
|
|
241
245
|
weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
|
|
@@ -401,13 +405,16 @@ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict
|
|
|
401
405
|
key = _state_key(account)
|
|
402
406
|
cached = all_cache.get(key)
|
|
403
407
|
if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
|
|
404
|
-
|
|
408
|
+
cached_reason = cached.get("unread_reason")
|
|
409
|
+
return Reading(account, _meters_from_payload(cached.get("meters")), cached_reason if isinstance(cached_reason, str) else None), False
|
|
410
|
+
unread_reason = None
|
|
405
411
|
try:
|
|
406
412
|
meters = adapter.read_meters(account)
|
|
407
|
-
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
|
413
|
+
except (codex_account_meters.CodexMeterUnreadError, OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
408
414
|
meters = None
|
|
409
|
-
|
|
410
|
-
|
|
415
|
+
unread_reason = str(error) or type(error).__name__
|
|
416
|
+
all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters), "unread_reason": unread_reason}
|
|
417
|
+
return Reading(account, meters, unread_reason), True
|
|
411
418
|
|
|
412
419
|
|
|
413
420
|
def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
|
|
@@ -158,6 +158,7 @@ class Meters:
|
|
|
158
158
|
class Reading:
|
|
159
159
|
account: Account
|
|
160
160
|
meters: Meters | None
|
|
161
|
+
unread_reason: str | None = None
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
@dataclass(frozen=True)
|
|
@@ -318,6 +319,12 @@ class _RetryTime:
|
|
|
318
319
|
is_known_reset: bool
|
|
319
320
|
|
|
320
321
|
|
|
322
|
+
def _unreadable_text(reading: Reading) -> str:
|
|
323
|
+
if reading.unread_reason is None:
|
|
324
|
+
return reading.account.name
|
|
325
|
+
return f"{reading.account.name} ({reading.unread_reason})"
|
|
326
|
+
|
|
327
|
+
|
|
321
328
|
def _wait_reason(all_unreadable_names: Sequence[str], is_every_account_unreadable: bool, soonest: _RetryTime) -> str:
|
|
322
329
|
next_event = "reset" if soonest.is_known_reset else "check"
|
|
323
330
|
next_text = f"next {next_event} at {utc_time_text(soonest.at)}"
|
|
@@ -341,7 +348,7 @@ def _wait_decision(
|
|
|
341
348
|
all_retry_times.append(_RetryTime(reset, is_known_reset=True) if reset is not None and reset > now else next_check)
|
|
342
349
|
soonest = min(all_retry_times, key=lambda each_retry_time: each_retry_time.at, default=next_check)
|
|
343
350
|
all_unreadable_names = [
|
|
344
|
-
each_reading
|
|
351
|
+
_unreadable_text(each_reading)
|
|
345
352
|
for each_reading in all_readings
|
|
346
353
|
if each_reading.meters is None and each_reading.account not in all_spent_accounts
|
|
347
354
|
]
|
|
@@ -75,6 +75,31 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
|
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room() -> None:
|
|
79
|
+
codex_3 = _account("codex-3")
|
|
80
|
+
codex_4 = _account("codex-4")
|
|
81
|
+
|
|
82
|
+
def read_meter(account: Account) -> Meters:
|
|
83
|
+
if account is codex_3:
|
|
84
|
+
raise OSError("codex app-server failed: timed out")
|
|
85
|
+
return _meters(0, 40, short_reset=NOW + timedelta(hours=2))
|
|
86
|
+
|
|
87
|
+
adapter = ProductAdapter(lambda: (codex_3, codex_4), read_meter, "CODEX_HOME", ())
|
|
88
|
+
state = account_broker._load_state(account_broker.broker_state_path())
|
|
89
|
+
readings = account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
|
|
90
|
+
|
|
91
|
+
decision = choose_from_readings(Product.CODEX, readings, now=NOW)
|
|
92
|
+
payload = account_broker.readings_payload(readings)
|
|
93
|
+
|
|
94
|
+
assert decision.action == "wait"
|
|
95
|
+
assert decision.reason == (
|
|
96
|
+
"no readable account has room; meter unreadable for codex-3 (codex app-server failed: timed out); "
|
|
97
|
+
"next check at 2026-10-03T01:00:00+00:00"
|
|
98
|
+
)
|
|
99
|
+
assert payload[0]["unread_reason"] == "codex app-server failed: timed out"
|
|
100
|
+
assert payload[1]["unread_reason"] is None
|
|
101
|
+
|
|
102
|
+
|
|
78
103
|
def test_should_rank_by_room_in_the_tighter_window() -> None:
|
|
79
104
|
readings = (
|
|
80
105
|
Reading(_account("first"), _meters(12, 80)),
|
|
@@ -137,6 +137,59 @@ def test_should_read_codex_meters_through_injected_reader(
|
|
|
137
137
|
assert meters.weekly_percent_left == 70.0
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _codex_meter_reads(
|
|
141
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, all_outcomes: list[object]
|
|
142
|
+
) -> list[Path]:
|
|
143
|
+
all_homes_read: list[Path] = []
|
|
144
|
+
|
|
145
|
+
def read_codex_meters(_codex_path: Path, codex_home: Path) -> object:
|
|
146
|
+
all_homes_read.append(codex_home)
|
|
147
|
+
outcome = all_outcomes.pop(0)
|
|
148
|
+
if isinstance(outcome, Exception):
|
|
149
|
+
raise outcome
|
|
150
|
+
return outcome
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
|
|
153
|
+
monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", read_codex_meters)
|
|
154
|
+
return all_homes_read
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_read_codex_meters_again_after_one_failed_read(
|
|
158
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
|
|
161
|
+
usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
|
|
162
|
+
unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
163
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, usage])
|
|
164
|
+
|
|
165
|
+
meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
|
|
166
|
+
|
|
167
|
+
assert meters is not None
|
|
168
|
+
assert meters.weekly_percent_left == 70.0
|
|
169
|
+
assert all_homes_read == [tmp_path, tmp_path]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_should_keep_the_unread_reason_after_two_failed_codex_reads(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
174
|
+
) -> None:
|
|
175
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
176
|
+
first_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server failed: timed out")
|
|
177
|
+
second_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
178
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, second_unread])
|
|
179
|
+
account = Account(Product.CODEX, "codex-3", tmp_path)
|
|
180
|
+
adapter = ProductAdapter(lambda: (account,), support.read_codex_account_meters, "CODEX_HOME", ())
|
|
181
|
+
state = support._load_state(support.broker_state_path())
|
|
182
|
+
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
183
|
+
|
|
184
|
+
first_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
185
|
+
cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
186
|
+
|
|
187
|
+
assert first_reading.meters is None
|
|
188
|
+
assert first_reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
189
|
+
assert cached_reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
190
|
+
assert all_homes_read == [tmp_path, tmp_path]
|
|
191
|
+
|
|
192
|
+
|
|
140
193
|
def test_should_cache_account_reading_under_injected_state_path(
|
|
141
194
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
142
195
|
) -> None:
|