claude-dev-env 2.1.0 → 2.2.1
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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/check.ps1 +32 -6
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/autoconverge/CLAUDE.md +5 -3
- package/skills/autoconverge/SKILL.md +185 -74
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/copilot-finding-triage/SKILL.md +16 -4
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +236 -44
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -15,29 +15,38 @@ Use on **draft PR**. Cursor Bugbot and `/bugteam` re-run after each push. Fix
|
|
|
15
15
|
findings between rounds until back-to-back clean on same `HEAD`, then mark
|
|
16
16
|
PR ready for review.
|
|
17
17
|
|
|
18
|
-
Run every tick in parent harness session. Pacing
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Run every tick in the parent harness session. Pacing depends on the selected
|
|
19
|
+
pacer from pre-flight (`select_converge_pacer.py`):
|
|
20
|
+
|
|
21
|
+
- `schedule_wakeup` — [`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md)
|
|
22
|
+
- `portable` — [`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
|
|
23
|
+
|
|
24
|
+
See [Pacing workflow](#pacing-workflow).
|
|
21
25
|
|
|
22
26
|
Every BUGTEAM tick runs **bugteam** — never hand-rolled substitute. Fix
|
|
23
|
-
protocol per [fix-protocol.md](fix-protocol.md). Pacing stays in main
|
|
24
|
-
`ScheduleWakeup`
|
|
27
|
+
protocol per [fix-protocol.md](fix-protocol.md). Pacing stays in the main
|
|
28
|
+
session (native `ScheduleWakeup` or the portable continuous driver).
|
|
25
29
|
|
|
26
30
|
## Invocation modes
|
|
27
31
|
|
|
28
|
-
- **`/pr-converge`** runs one tick, then Step 4
|
|
29
|
-
`ScheduleWakeup`. Omit the next wakeup only on
|
|
30
|
-
conditions**.
|
|
32
|
+
- **`/pr-converge` with `pacer=schedule_wakeup`** runs one tick, then Step 4
|
|
33
|
+
schedules the next via `ScheduleWakeup`. Omit the next wakeup only on
|
|
34
|
+
convergence or **Stop conditions**.
|
|
35
|
+
- **`/pr-converge` with `pacer=portable`** runs ticks continuously in-session
|
|
36
|
+
(poll waits at the same delays); write handoff at budget boundaries. See
|
|
37
|
+
the portable driver.
|
|
31
38
|
|
|
32
39
|
## Pacing workflow
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
When `pacer=schedule_wakeup`, read
|
|
42
|
+
[`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md)
|
|
35
43
|
(installed copy under `$HOME/.claude/skills/pr-converge/workflows/`) before
|
|
36
|
-
Step 4.
|
|
37
|
-
|
|
44
|
+
Step 4. When `pacer=portable`, read
|
|
45
|
+
[`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
|
|
46
|
+
and skip `ScheduleWakeup` calls.
|
|
38
47
|
|
|
39
48
|
- **`/pr-converge`** (default): loops until convergence. After each tick
|
|
40
|
-
(unless converged or stopped), run **Step 4
|
|
49
|
+
(unless converged or stopped), run **Step 4** using the selected pacer.
|
|
41
50
|
|
|
42
51
|
## Step 1: Resolve current HEAD and PR context
|
|
43
52
|
|
|
@@ -244,8 +253,8 @@ c. Decide (three branches; match first whose predicate holds):
|
|
|
244
253
|
|
|
245
254
|
- **Failed review (`returncode != 0`, or chain mode with null
|
|
246
255
|
`served_command`):** The review did not complete a successful serve. Do
|
|
247
|
-
not set `code_review_clean_at`. Stay `phase = CODE_REVIEW`,
|
|
248
|
-
|
|
256
|
+
not set `code_review_clean_at`. Stay `phase = CODE_REVIEW`, apply Step 4 pacer
|
|
257
|
+
(ScheduleWakeup or portable continue/poll), return. A failed chain often leaves `dirty_tree` false — that is
|
|
249
258
|
not a clean stamp.
|
|
250
259
|
- **Fixes applied (working tree dirty / `dirty_tree` true):** Commit the
|
|
251
260
|
applied fixes in one commit → push, following the shared fix protocol
|
|
@@ -253,7 +262,7 @@ c. Decide (three branches; match first whose predicate holds):
|
|
|
253
262
|
push-invalidated markers per [ground-rules.md](ground-rules.md) /
|
|
254
263
|
[state-schema.md](state-schema.md) (all `*_clean_at`, `merge_state_status`,
|
|
255
264
|
`bugbot_down`, `bugbot_acknowledged_at`, `codex_down`). Stay
|
|
256
|
-
`phase = CODE_REVIEW`,
|
|
265
|
+
`phase = CODE_REVIEW`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return. Every fix push
|
|
257
266
|
re-enters the internal passes on the new HEAD.
|
|
258
267
|
- **Clean (successful serve: `returncode == 0`, chain `served_command`
|
|
259
268
|
non-null when `mode == chain`, and `dirty_tree` false):** Set
|
|
@@ -318,7 +327,7 @@ d. Decide based on post-bugteam state — order matters. Check
|
|
|
318
327
|
pushed-during-bugteam FIRST so a convergence report against a stale HEAD
|
|
319
328
|
never falsely terminates:
|
|
320
329
|
- **Audit pushed this tick (clean-at fields reset in step b):**
|
|
321
|
-
`phase = CODE_REVIEW`,
|
|
330
|
+
`phase = CODE_REVIEW`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return. Every fix push
|
|
322
331
|
re-enters the internal passes on the new HEAD.
|
|
323
332
|
- **Convergence AND no push:** the internal passes are clean on
|
|
324
333
|
`current_head`. Stamp `bugteam_clean_at = current_head`, then
|
|
@@ -328,8 +337,8 @@ never falsely terminates:
|
|
|
328
337
|
([`../../../_shared/pr-loop/fix-protocol.md`](../../../_shared/pr-loop/fix-protocol.md); skill deltas in [fix-protocol.md](fix-protocol.md)). Reset push-invalidated markers
|
|
329
338
|
per [ground-rules.md](ground-rules.md) / [state-schema.md](state-schema.md)
|
|
330
339
|
(all `*_clean_at`, `merge_state_status`, `bugbot_down`,
|
|
331
|
-
`bugbot_acknowledged_at`, `codex_down`), `phase = CODE_REVIEW`,
|
|
332
|
-
|
|
340
|
+
`bugbot_acknowledged_at`, `codex_down`), `phase = CODE_REVIEW`, apply Step 4 pacer
|
|
341
|
+
(ScheduleWakeup or portable continue/poll), return.
|
|
333
342
|
|
|
334
343
|
### `phase == BUGBOT` (terminal gate)
|
|
335
344
|
|
|
@@ -386,7 +395,7 @@ pull_request_read(owner=OWNER, repo=REPO, pullNumber=NUMBER, method="get_review_
|
|
|
386
395
|
c. Decide (four branches; match first whose predicate holds):
|
|
387
396
|
- **No bugbot review yet, OR latest review's `commit_id` ≠
|
|
388
397
|
`current_head`:** Re-trigger bugbot (Step 3), set `bugbot_clean_at =
|
|
389
|
-
null`, reset `inline_lag_streak = 0`,
|
|
398
|
+
null`, reset `inline_lag_streak = 0`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return to the
|
|
390
399
|
Bugbot gate next tick.
|
|
391
400
|
- **`commit_id == current_head` AND zero unaddressed inline AND review
|
|
392
401
|
body clean:** Set `bugbot_clean_at = current_head`, reset
|
|
@@ -402,7 +411,7 @@ c. Decide (four branches; match first whose predicate holds):
|
|
|
402
411
|
`state.json`: the clean-coder teammate executes the fix, writes
|
|
403
412
|
`state.json`, goes idle; the next tick re-enters CODE_REVIEW on the new
|
|
404
413
|
HEAD. No `state.json` (single-PR): the lead executes it, stays
|
|
405
|
-
`phase = CODE_REVIEW`.
|
|
414
|
+
`phase = CODE_REVIEW`. Apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return.
|
|
406
415
|
|
|
407
416
|
### `phase == COPILOT_WAIT`
|
|
408
417
|
|
|
@@ -435,12 +444,12 @@ b. Decide (three branches; match first whose predicate holds):
|
|
|
435
444
|
(all `*_clean_at`, `merge_state_status`, `bugbot_down`,
|
|
436
445
|
`bugbot_acknowledged_at`, `codex_down`). **Set `phase = CODE_REVIEW`**
|
|
437
446
|
(NOT COPILOT_WAIT) — every fix push re-enters the internal passes on the
|
|
438
|
-
new HEAD.
|
|
447
|
+
new HEAD. Apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return.
|
|
439
448
|
- **No Copilot review at `current_head` yet:** Increment
|
|
440
449
|
`copilot_wait_count` (init 0 on COPILOT_WAIT entry; reset to 0 on
|
|
441
450
|
every push and on every successful Copilot review). `>= 3` → hard
|
|
442
451
|
blocker per [stop-conditions.md](stop-conditions.md). Otherwise
|
|
443
|
-
|
|
452
|
+
apply Step 4 pacer (ScheduleWakeup or portable continue/poll; 360s wait), return.
|
|
444
453
|
|
|
445
454
|
**Non-negotiable:** After any Copilot fix push, `phase` MUST route to
|
|
446
455
|
`CODE_REVIEW`. Never cycle COPILOT_WAIT → fix → COPILOT_WAIT. The
|
|
@@ -508,8 +517,11 @@ base), never an invented commit range.
|
|
|
508
517
|
|
|
509
518
|
## Step 4: Loop pacing
|
|
510
519
|
|
|
511
|
-
|
|
512
|
-
|
|
520
|
+
Apply the pacer selected at pre-flight ([Pacing workflow](#pacing-workflow)).
|
|
521
|
+
|
|
522
|
+
### `pacer=schedule_wakeup`
|
|
523
|
+
|
|
524
|
+
**`ScheduleWakeup` field hints:**
|
|
513
525
|
|
|
514
526
|
- `delaySeconds: 360` after bugbot re-trigger. Exception:
|
|
515
527
|
BUGBOT inline-lag branch uses `delaySeconds: 90` (no re-trigger;
|
|
@@ -521,6 +533,21 @@ workflow](#pacing-workflow)):
|
|
|
521
533
|
**On convergence:** apply **Convergence** section of
|
|
522
534
|
`../workflows/schedule-wakeup-loop.md` (omit wakeups).
|
|
523
535
|
|
|
536
|
+
### `pacer=portable`
|
|
537
|
+
|
|
538
|
+
Do **not** call `ScheduleWakeup`. After writing state and handoff:
|
|
539
|
+
|
|
540
|
+
- **Immediate work next** → continue the next tick in the same turn.
|
|
541
|
+
- **Wait next** (Bugbot queued, COPILOT_WAIT with no review yet) → poll
|
|
542
|
+
in-session for the same delay (`360` default, `90` on Bugbot inline-lag),
|
|
543
|
+
then resume the same step. Honor the same hard caps as the ScheduleWakeup
|
|
544
|
+
path.
|
|
545
|
+
- **Budget too low for a full tick** → stop at the tick boundary; print
|
|
546
|
+
`/pr-converge <PR URL>` and the persisted phase.
|
|
547
|
+
|
|
548
|
+
Full portable rules:
|
|
549
|
+
[`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md).
|
|
550
|
+
|
|
524
551
|
## Bugteam execution
|
|
525
552
|
|
|
526
553
|
**Second audit** (BUGTEAM phase) is **always** **bugteam** skill: preflight,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Contract checks: pr-converge selects a portable pacer when ScheduleWakeup is absent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SKILL_ROOT = Path(__file__).resolve().parent
|
|
9
|
+
SKILL_MARKDOWN_PATH = SKILL_ROOT / "SKILL.md"
|
|
10
|
+
PER_TICK_MARKDOWN_PATH = SKILL_ROOT / "reference" / "per-tick.md"
|
|
11
|
+
PORTABLE_DRIVER_PATH = (
|
|
12
|
+
SKILL_ROOT.parent / "_shared" / "pr-loop" / "portable-driver.md"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
SELECT_CONVERGE_PACER_SCRIPT = "select_converge_pacer.py"
|
|
16
|
+
PACER_PORTABLE_TOKEN = "pacer=portable"
|
|
17
|
+
PACER_SCHEDULE_WAKEUP_TOKEN = "pacer=schedule_wakeup"
|
|
18
|
+
ABORT_ONLY_PHRASE = "pr-converge requires ScheduleWakeup; aborting"
|
|
19
|
+
PORTABLE_DRIVER_DOC = "portable-driver.md"
|
|
20
|
+
PORTABLE_DRIVER_LINK_PATTERN = re.compile(
|
|
21
|
+
r"\((?P<relative>(?:\.\./)+_shared/pr-loop/portable-driver\.md)\)"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_skill_selects_pacer_and_forbids_abort_only_schedule_wakeup() -> None:
|
|
26
|
+
skill_text = SKILL_MARKDOWN_PATH.read_text(encoding="utf-8")
|
|
27
|
+
assert SELECT_CONVERGE_PACER_SCRIPT in skill_text
|
|
28
|
+
assert PACER_PORTABLE_TOKEN in skill_text
|
|
29
|
+
assert PACER_SCHEDULE_WAKEUP_TOKEN in skill_text
|
|
30
|
+
assert PORTABLE_DRIVER_DOC in skill_text
|
|
31
|
+
assert ABORT_ONLY_PHRASE not in skill_text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_skill_portable_driver_links_resolve_on_disk() -> None:
|
|
35
|
+
skill_text = SKILL_MARKDOWN_PATH.read_text(encoding="utf-8")
|
|
36
|
+
all_relative_links = PORTABLE_DRIVER_LINK_PATTERN.findall(skill_text)
|
|
37
|
+
assert all_relative_links, "SKILL.md must link portable-driver.md"
|
|
38
|
+
for each_relative_link in all_relative_links:
|
|
39
|
+
resolved_path = (SKILL_MARKDOWN_PATH.parent / each_relative_link).resolve()
|
|
40
|
+
assert resolved_path.is_file(), (
|
|
41
|
+
f"portable-driver link must resolve on disk: "
|
|
42
|
+
f"{each_relative_link} -> {resolved_path}"
|
|
43
|
+
)
|
|
44
|
+
assert resolved_path == PORTABLE_DRIVER_PATH.resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_per_tick_documents_both_pacers() -> None:
|
|
48
|
+
per_tick_text = PER_TICK_MARKDOWN_PATH.read_text(encoding="utf-8")
|
|
49
|
+
assert "portable" in per_tick_text
|
|
50
|
+
assert "schedule_wakeup" in per_tick_text or "ScheduleWakeup" in per_tick_text
|
|
51
|
+
assert PORTABLE_DRIVER_DOC in per_tick_text
|
|
52
|
+
assert "pacer=portable" in per_tick_text
|
|
53
|
+
assert "Do **not** call `ScheduleWakeup`" in per_tick_text or (
|
|
54
|
+
"Do not call `ScheduleWakeup`" in per_tick_text
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_portable_driver_protocol_exists() -> None:
|
|
59
|
+
assert PORTABLE_DRIVER_PATH.is_file()
|
|
60
|
+
portable_text = PORTABLE_DRIVER_PATH.read_text(encoding="utf-8")
|
|
61
|
+
assert "check_convergence.py" in portable_text
|
|
62
|
+
assert "invoke_code_review.py" in portable_text
|
|
63
|
+
assert "resolve_worker_spawn.py" in portable_text
|
|
64
|
+
assert "build_converge_task_list.py" in portable_text
|
|
65
|
+
assert "all_runnable_reviews_clean_same_head" in portable_text
|
|
66
|
+
assert "portable_converge_driver.py" in portable_text
|
|
67
|
+
assert "Never abort solely because" in portable_text
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# ScheduleWakeup loop pacing (pr-converge)
|
|
2
2
|
|
|
3
|
-
Load this document
|
|
4
|
-
|
|
5
|
-
/
|
|
3
|
+
Load this document when pre-flight selects `pacer=schedule_wakeup`. When
|
|
4
|
+
`pacer=portable`, use
|
|
5
|
+
[`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
|
|
6
|
+
instead. Shared bugbot / bugteam / Fix protocol steps stay in the main
|
|
7
|
+
`SKILL.md`.
|
|
6
8
|
|
|
7
9
|
## Calling ScheduleWakeup
|
|
8
10
|
|
|
@@ -41,10 +41,10 @@ On exit 2 the script prints `{"error": ...}`. Ask the user for a manual reset ti
|
|
|
41
41
|
|
|
42
42
|
`scripts/resolve_usage_window.py` is the source of truth for live probe behavior. Endpoint URL, header names/values, credential path and token keys, response bucket keys, stage sizing, and the weekly warn threshold all live in `scripts/usage_pause_constants/resolve_usage_window_constants.py` — read those modules for the current values; do not restate them here.
|
|
43
43
|
|
|
44
|
-
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources
|
|
44
|
+
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources depend on the host:
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
- **Desktop host** (the `CLAUDE_CODE_ENTRYPOINT` variable is `claude-desktop`): the resolver does not read the CLI credential file, which belongs to a different authentication session than the one the desktop app counts usage against. It uses the session ingress token when one is set, and otherwise takes the manual-override path.
|
|
47
|
+
- **Every other host**: the resolver reads the Claude Code CLI's stored OAuth access token first (honored only while unexpired), then the session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions) when the credential token is unavailable.
|
|
48
48
|
|
|
49
49
|
Fallbacks, in order: both token sources unavailable (expired/unreadable credential and no ingress file), a failed request, or a response with no readable session-window reset time all end in exit 2 — the manual-override ask above. The manual path works with no probe at all, so the skill functions even when both token sources are unavailable.
|
|
50
50
|
|
|
@@ -106,8 +106,9 @@ Fill each `<slot>` at schedule time: `<remaining_stage_durations>` is the tail o
|
|
|
106
106
|
| `SKILL.md` | This flow: resolve, weekly guard, stage chain, templates |
|
|
107
107
|
| `scripts/resolve_usage_window.py` | The window resolver and stage planner CLI |
|
|
108
108
|
| `scripts/test_resolve_usage_window.py` | Behavioral tests for parsing, staging, token reading, extraction, CLI |
|
|
109
|
-
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, stage sizing, thresholds, result keys |
|
|
109
|
+
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, host detection, stage sizing, thresholds, result keys |
|
|
110
110
|
|
|
111
111
|
## Gotchas
|
|
112
112
|
|
|
113
113
|
- The stored access token lives about 8 hours and the CLI rewrites it on its own schedule, so a mid-afternoon probe can find it expired even while the CLI itself still works. That is the designed exit-2 path: give a manual time.
|
|
114
|
+
- On the desktop host the resolver never reads the CLI credential file, because that credential is a different authentication session than the one the desktop app counts usage against. With no session ingress token set, the desktop host takes the manual path: read the reset time from the interactive `/usage` panel and pass it, for example `/usage-pause 10:20pm`.
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
{"source": "override", "reset_at": "2026-07-08T10:14:00-07:00",
|
|
8
8
|
"seconds_until_reset": 4440, "stages_seconds": [3480, 960, 120], ...}
|
|
9
9
|
|
|
10
|
-
With no ``--override``, the script resolves a bearer token
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
With no ``--override``, the script resolves a bearer token. On the desktop
|
|
11
|
+
host it uses only the session ingress token. On every other host it reads the
|
|
12
|
+
Claude Code OAuth access token from the CLI credential file, then the session
|
|
13
|
+
ingress token file when that credential is unavailable. It asks the OAuth
|
|
14
|
+
usage endpoint for the ``five_hour`` and ``seven_day`` windows. Exit code 2
|
|
15
|
+
means the probe cannot resolve; the caller then asks the user for a manual
|
|
16
|
+
reset time.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -41,7 +43,9 @@ from usage_pause_constants.resolve_usage_window_constants import (
|
|
|
41
43
|
CREDENTIALS_ACCESS_TOKEN_KEY,
|
|
42
44
|
CREDENTIALS_EXPIRES_AT_KEY,
|
|
43
45
|
CREDENTIALS_OAUTH_SECTION_KEY,
|
|
46
|
+
DESKTOP_ENTRYPOINT_VALUE,
|
|
44
47
|
DURATION_PATTERN,
|
|
48
|
+
ENTRYPOINT_ENV_VAR,
|
|
45
49
|
EPOCH_MILLISECONDS_THRESHOLD,
|
|
46
50
|
EXIT_CODE_PROBE_UNAVAILABLE,
|
|
47
51
|
EXIT_CODE_RESOLVED,
|
|
@@ -267,14 +271,36 @@ def read_session_ingress_token() -> str | None:
|
|
|
267
271
|
return token_text
|
|
268
272
|
|
|
269
273
|
|
|
274
|
+
def running_on_desktop_host() -> bool:
|
|
275
|
+
"""Tell whether this process runs under the Claude desktop app.
|
|
276
|
+
|
|
277
|
+
::
|
|
278
|
+
|
|
279
|
+
entrypoint is the desktop marker -> True
|
|
280
|
+
entrypoint is the CLI or unset -> False
|
|
281
|
+
|
|
282
|
+
The desktop app meters its session under a different auth session than
|
|
283
|
+
the CLI credential file, so a true answer tells the resolver to leave
|
|
284
|
+
that credential file alone.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True when the entrypoint environment variable names the desktop app.
|
|
288
|
+
"""
|
|
289
|
+
return os.environ.get(ENTRYPOINT_ENV_VAR) == DESKTOP_ENTRYPOINT_VALUE
|
|
290
|
+
|
|
291
|
+
|
|
270
292
|
def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
271
293
|
"""Choose the usage-endpoint bearer token from its available sources.
|
|
272
294
|
|
|
273
295
|
::
|
|
274
296
|
|
|
275
|
-
credential
|
|
276
|
-
credential token unavailable
|
|
277
|
-
|
|
297
|
+
CLI host, credential token valid -> the credential token
|
|
298
|
+
CLI host, credential token unavailable -> the session ingress token
|
|
299
|
+
desktop host -> the session ingress token
|
|
300
|
+
no source available -> None
|
|
301
|
+
|
|
302
|
+
On the desktop host the CLI credential file belongs to a different auth
|
|
303
|
+
session, so it is skipped and only the session ingress token is honored.
|
|
278
304
|
|
|
279
305
|
Args:
|
|
280
306
|
credentials_path: The CLI credential file holding the OAuth section.
|
|
@@ -283,6 +309,8 @@ def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
|
283
309
|
Returns:
|
|
284
310
|
The bearer token for the usage endpoint, or None when no source has one.
|
|
285
311
|
"""
|
|
312
|
+
if running_on_desktop_host():
|
|
313
|
+
return read_session_ingress_token()
|
|
286
314
|
credential_token = read_oauth_access_token(credentials_path, now)
|
|
287
315
|
return credential_token or read_session_ingress_token()
|
|
288
316
|
|
|
@@ -447,6 +475,37 @@ def _describe_ingress_token_source() -> str:
|
|
|
447
475
|
return f"the session ingress token file ({SESSION_INGRESS_TOKEN_FILE_ENV_VAR} set but empty)"
|
|
448
476
|
|
|
449
477
|
|
|
478
|
+
def _no_token_error_message(credentials_path: Path) -> str:
|
|
479
|
+
"""Build the no-usable-token exit-2 error, host-aware for the desktop app.
|
|
480
|
+
|
|
481
|
+
::
|
|
482
|
+
|
|
483
|
+
desktop host -> names the different-session skip and the /usage remedy
|
|
484
|
+
other host -> names the credential path and the ingress source tried
|
|
485
|
+
|
|
486
|
+
On the desktop host the CLI credential file is never read, so the message
|
|
487
|
+
points the user at the interactive /usage panel for a manual reset time.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
credentials_path: The CLI credential file the non-desktop message names.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
The error text for the no-usable-token exit-2 path.
|
|
494
|
+
"""
|
|
495
|
+
if running_on_desktop_host():
|
|
496
|
+
return (
|
|
497
|
+
"on the desktop host the CLI credential file belongs to a different "
|
|
498
|
+
f"auth session and is not read, and {_describe_ingress_token_source()} "
|
|
499
|
+
"has no token; read the reset time from the interactive /usage panel "
|
|
500
|
+
"and give it manually, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
501
|
+
)
|
|
502
|
+
return (
|
|
503
|
+
"no usable bearer token from the OAuth credential file at "
|
|
504
|
+
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
505
|
+
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
450
509
|
def _parse_arguments() -> argparse.Namespace:
|
|
451
510
|
parser = argparse.ArgumentParser(
|
|
452
511
|
description="Resolve the 5-hour usage window reset and plan the pause stage chain.",
|
|
@@ -503,11 +562,7 @@ def main() -> int:
|
|
|
503
562
|
)
|
|
504
563
|
access_token = resolve_access_token(credentials_path, now)
|
|
505
564
|
if access_token is None:
|
|
506
|
-
return _emit_error(
|
|
507
|
-
"no usable bearer token from the OAuth credential file at "
|
|
508
|
-
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
509
|
-
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
510
|
-
)
|
|
565
|
+
return _emit_error(_no_token_error_message(credentials_path))
|
|
511
566
|
try:
|
|
512
567
|
usage_payload = _fetch_usage_payload(access_token)
|
|
513
568
|
except (
|
|
@@ -18,7 +18,9 @@ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
|
18
18
|
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
19
19
|
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
20
20
|
|
|
21
|
-
from usage_pause_constants.resolve_usage_window_constants import (
|
|
21
|
+
from usage_pause_constants.resolve_usage_window_constants import ( # noqa: E402
|
|
22
|
+
DESKTOP_ENTRYPOINT_VALUE,
|
|
23
|
+
ENTRYPOINT_ENV_VAR,
|
|
22
24
|
SESSION_INGRESS_TOKEN_FILE_ENV_VAR as INGRESS_TOKEN_FILE_ENV_VAR,
|
|
23
25
|
)
|
|
24
26
|
|
|
@@ -188,8 +190,7 @@ class TestReadOauthAccessToken:
|
|
|
188
190
|
credentials_path.write_text("{not valid json", encoding="utf-8")
|
|
189
191
|
with caplog.at_level(logging.WARNING):
|
|
190
192
|
assert (
|
|
191
|
-
resolver.read_oauth_access_token(credentials_path, local_now())
|
|
192
|
-
is None
|
|
193
|
+
resolver.read_oauth_access_token(credentials_path, local_now()) is None
|
|
193
194
|
)
|
|
194
195
|
assert any("unreadable" in each_message for each_message in caplog.messages)
|
|
195
196
|
|
|
@@ -240,7 +241,43 @@ class TestReadSessionIngressToken:
|
|
|
240
241
|
assert INGRESS_TOKEN_FILE_ENV_VAR == "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
|
|
241
242
|
|
|
242
243
|
|
|
244
|
+
class TestRunningOnDesktopHost:
|
|
245
|
+
def should_be_true_when_entrypoint_names_the_desktop_app(
|
|
246
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
247
|
+
) -> None:
|
|
248
|
+
resolver = load_resolver_module()
|
|
249
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
250
|
+
assert resolver.running_on_desktop_host() is True
|
|
251
|
+
|
|
252
|
+
def should_be_false_when_entrypoint_names_a_non_desktop_host(
|
|
253
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
254
|
+
) -> None:
|
|
255
|
+
resolver = load_resolver_module()
|
|
256
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
|
|
257
|
+
assert resolver.running_on_desktop_host() is False
|
|
258
|
+
|
|
259
|
+
def should_be_false_when_entrypoint_is_unset(
|
|
260
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
261
|
+
) -> None:
|
|
262
|
+
resolver = load_resolver_module()
|
|
263
|
+
monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
|
|
264
|
+
assert resolver.running_on_desktop_host() is False
|
|
265
|
+
|
|
266
|
+
def should_pin_the_desktop_entrypoint_contract_to_the_literal_marker(
|
|
267
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
268
|
+
) -> None:
|
|
269
|
+
resolver = load_resolver_module()
|
|
270
|
+
monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "claude-desktop")
|
|
271
|
+
assert resolver.running_on_desktop_host() is True
|
|
272
|
+
assert ENTRYPOINT_ENV_VAR == "CLAUDE_CODE_ENTRYPOINT"
|
|
273
|
+
assert DESKTOP_ENTRYPOINT_VALUE == "claude-desktop"
|
|
274
|
+
|
|
275
|
+
|
|
243
276
|
class TestResolveAccessToken:
|
|
277
|
+
@pytest.fixture(autouse=True)
|
|
278
|
+
def force_non_desktop_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
279
|
+
monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
|
|
280
|
+
|
|
244
281
|
def should_use_ingress_token_when_credential_file_missing(
|
|
245
282
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
246
283
|
) -> None:
|
|
@@ -295,6 +332,57 @@ class TestResolveAccessToken:
|
|
|
295
332
|
assert resolver.resolve_access_token(missing_credentials, local_now()) is None
|
|
296
333
|
|
|
297
334
|
|
|
335
|
+
class TestResolveAccessTokenHostAware:
|
|
336
|
+
def should_ignore_a_valid_cli_credential_token_on_the_desktop_host(
|
|
337
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
338
|
+
) -> None:
|
|
339
|
+
resolver = load_resolver_module()
|
|
340
|
+
now = local_now()
|
|
341
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
342
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
343
|
+
write_credentials(
|
|
344
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
345
|
+
)
|
|
346
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
347
|
+
monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
|
|
348
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
349
|
+
assert chosen_token != "cli-credential-token"
|
|
350
|
+
assert chosen_token is None
|
|
351
|
+
|
|
352
|
+
def should_use_the_ingress_token_over_the_cli_credential_on_the_desktop_host(
|
|
353
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
354
|
+
) -> None:
|
|
355
|
+
resolver = load_resolver_module()
|
|
356
|
+
now = local_now()
|
|
357
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
358
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
359
|
+
write_credentials(
|
|
360
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
361
|
+
)
|
|
362
|
+
token_file = tmp_path / "ingress-token"
|
|
363
|
+
token_file.write_text("ingress-token-value", encoding="utf-8")
|
|
364
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
365
|
+
monkeypatch.setenv(INGRESS_TOKEN_FILE_ENV_VAR, str(token_file))
|
|
366
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
367
|
+
assert chosen_token == "ingress-token-value"
|
|
368
|
+
assert chosen_token != "cli-credential-token"
|
|
369
|
+
|
|
370
|
+
def should_return_the_cli_credential_token_on_a_non_desktop_host(
|
|
371
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
372
|
+
) -> None:
|
|
373
|
+
resolver = load_resolver_module()
|
|
374
|
+
now = local_now()
|
|
375
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
376
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
377
|
+
write_credentials(
|
|
378
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
379
|
+
)
|
|
380
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
|
|
381
|
+
monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
|
|
382
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
383
|
+
assert chosen_token == "cli-credential-token"
|
|
384
|
+
|
|
385
|
+
|
|
298
386
|
class TestExtractUsageWindows:
|
|
299
387
|
def should_extract_session_and_weekly_buckets(self) -> None:
|
|
300
388
|
resolver = load_resolver_module()
|
|
@@ -361,13 +449,20 @@ class TestBuildPausePlan:
|
|
|
361
449
|
|
|
362
450
|
class TestCommandLine:
|
|
363
451
|
def run_resolver(
|
|
364
|
-
self,
|
|
452
|
+
self,
|
|
453
|
+
*arguments: str,
|
|
454
|
+
ingress_token_file: str | None = None,
|
|
455
|
+
entrypoint: str | None = None,
|
|
365
456
|
) -> subprocess.CompletedProcess[str]:
|
|
366
457
|
child_environment = dict(os.environ)
|
|
367
458
|
if ingress_token_file is None:
|
|
368
459
|
child_environment.pop(INGRESS_TOKEN_FILE_ENV_VAR, None)
|
|
369
460
|
else:
|
|
370
461
|
child_environment[INGRESS_TOKEN_FILE_ENV_VAR] = ingress_token_file
|
|
462
|
+
if entrypoint is None:
|
|
463
|
+
child_environment.pop(ENTRYPOINT_ENV_VAR, None)
|
|
464
|
+
else:
|
|
465
|
+
child_environment[ENTRYPOINT_ENV_VAR] = entrypoint
|
|
371
466
|
return subprocess.run(
|
|
372
467
|
[sys.executable, str(RESOLVER_PATH), *arguments],
|
|
373
468
|
capture_output=True,
|
|
@@ -403,9 +498,7 @@ class TestCommandLine:
|
|
|
403
498
|
assert str(missing_path) in resolved_payload["error"]
|
|
404
499
|
assert f"{INGRESS_TOKEN_FILE_ENV_VAR} unset" in resolved_payload["error"]
|
|
405
500
|
|
|
406
|
-
def should_name_the_ingress_token_path_in_the_error(
|
|
407
|
-
self, tmp_path: Path
|
|
408
|
-
) -> None:
|
|
501
|
+
def should_name_the_ingress_token_path_in_the_error(self, tmp_path: Path) -> None:
|
|
409
502
|
missing_credentials = tmp_path / "absent.json"
|
|
410
503
|
stale_ingress = tmp_path / "stale-ingress-token"
|
|
411
504
|
completed = self.run_resolver(
|
|
@@ -429,10 +522,29 @@ class TestCommandLine:
|
|
|
429
522
|
assert completed.returncode == 2
|
|
430
523
|
resolved_payload = json.loads(completed.stdout)
|
|
431
524
|
assert (
|
|
432
|
-
f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty"
|
|
433
|
-
in resolved_payload["error"]
|
|
525
|
+
f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty" in resolved_payload["error"]
|
|
434
526
|
)
|
|
435
527
|
|
|
528
|
+
def should_exit_two_with_a_host_aware_error_on_the_desktop_host(
|
|
529
|
+
self, tmp_path: Path
|
|
530
|
+
) -> None:
|
|
531
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
532
|
+
future_milliseconds = int((local_now() + timedelta(hours=1)).timestamp() * 1000)
|
|
533
|
+
write_credentials(
|
|
534
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
535
|
+
)
|
|
536
|
+
completed = self.run_resolver(
|
|
537
|
+
"--credentials-path",
|
|
538
|
+
str(credentials_path),
|
|
539
|
+
entrypoint="claude-desktop",
|
|
540
|
+
)
|
|
541
|
+
assert completed.returncode == 2
|
|
542
|
+
assert "cli-credential-token" not in completed.stdout
|
|
543
|
+
error_text = json.loads(completed.stdout)["error"]
|
|
544
|
+
assert "different auth session" in error_text
|
|
545
|
+
assert "/usage" in error_text
|
|
546
|
+
assert "/usage-pause" in error_text
|
|
547
|
+
|
|
436
548
|
def should_reject_invalid_override_with_error_payload(self) -> None:
|
|
437
549
|
completed = self.run_resolver("--override", "soon")
|
|
438
550
|
assert completed.returncode == 2
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Constants for the usage-window resolver.
|
|
2
2
|
|
|
3
3
|
Groups: the OAuth usage-endpoint probe, the CLI credential file keys, the
|
|
4
|
-
session ingress token file environment variable, the
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
session ingress token file environment variable, the host entrypoint
|
|
5
|
+
detection, the usage-response field keys, the override parse patterns, the
|
|
6
|
+
wakeup stage sizing, the weekly warn threshold, the result JSON keys, the
|
|
7
|
+
source labels, and the exit codes.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -24,6 +25,9 @@ CREDENTIALS_EXPIRES_AT_KEY = "expiresAt"
|
|
|
24
25
|
SESSION_INGRESS_TOKEN_FILE_ENV_VAR = "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
|
|
25
26
|
MILLISECONDS_PER_SECOND = 1000
|
|
26
27
|
|
|
28
|
+
ENTRYPOINT_ENV_VAR = "CLAUDE_CODE_ENTRYPOINT"
|
|
29
|
+
DESKTOP_ENTRYPOINT_VALUE = "claude-desktop"
|
|
30
|
+
|
|
27
31
|
FIVE_HOUR_BUCKET_KEY = "five_hour"
|
|
28
32
|
SEVEN_DAY_BUCKET_KEY = "seven_day"
|
|
29
33
|
UTILIZATION_KEY = "utilization"
|