claude-dev-env 8.38.2 → 8.39.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/_shared/advisor/reference/astra-rung.md +7 -7
- package/_shared/advisor/scripts/codex_astra_advisor.py +13 -13
- package/_shared/advisor/scripts/codex_astra_preflight.py +62 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +5 -2
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +64 -33
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +125 -3
- package/_shared/pr-loop/scripts/check_convergence.py +23 -17
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +4 -3
- package/_shared/pr-loop/scripts/test_check_convergence_codex.py +64 -25
- package/docs/codex-accounts.md +27 -31
- package/hooks/session/skill_loaded_reminder.py +54 -25
- package/hooks/session/test_skill_loaded_reminder.py +94 -12
- package/package.json +1 -1
- package/scripts/codex_account_choice.py +8 -303
- package/scripts/test_codex_account_choice.py +11 -273
|
@@ -29,10 +29,10 @@ The frozen process env is only a fallback when that disk read fails (logged once
|
|
|
29
29
|
A probe error does not waive the gate; the live GitHub checks still run.
|
|
30
30
|
|
|
31
31
|
The Codex gate is conditional-required: it demands
|
|
32
|
-
``codex_clean_at == current_head`` only when the
|
|
33
|
-
(``scripts/
|
|
32
|
+
``codex_clean_at == current_head`` only when the account broker
|
|
33
|
+
(``scripts/account_broker.py choose --product codex``) answers the ``normal`` tier, so
|
|
34
34
|
one signed-in account has room for a review. A ``luna`` or ``wait`` answer, an
|
|
35
|
-
unreadable
|
|
35
|
+
unreadable broker, the codex token, or ``codex_down`` never blocks ready.
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
@@ -64,11 +64,12 @@ from check_convergence_thread_gates import (
|
|
|
64
64
|
_count_unresolved_bot_threads,
|
|
65
65
|
)
|
|
66
66
|
from pr_converge_scripts_constants.convergence_gate_constants import (
|
|
67
|
-
|
|
67
|
+
ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS,
|
|
68
|
+
ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES,
|
|
68
69
|
BUGBOT_DOWN_BYPASS_NOTE,
|
|
69
70
|
CLAUDE_JOB_DIR_ENV_VAR_NAME,
|
|
70
71
|
CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS,
|
|
71
|
-
|
|
72
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
|
|
72
73
|
CODEX_BYPASS_DETAIL,
|
|
73
74
|
CODEX_CLEAN_AT_STATE_KEY,
|
|
74
75
|
CODEX_CLEAN_DETAIL_TEMPLATE,
|
|
@@ -344,16 +345,16 @@ def _evaluate_codex_clean(
|
|
|
344
345
|
|
|
345
346
|
::
|
|
346
347
|
|
|
347
|
-
clean stamp on head -> pass (
|
|
348
|
+
clean stamp on head -> pass (broker never runs)
|
|
348
349
|
no stamp, tier luna, wait, or None -> skip (never blocks)
|
|
349
350
|
no stamp, tier normal -> fail
|
|
350
351
|
|
|
351
|
-
A stamp on the current HEAD settles the gate on its own, so the
|
|
352
|
-
stays behind it: the
|
|
352
|
+
A stamp on the current HEAD settles the gate on its own, so the broker
|
|
353
|
+
stays behind it: the broker starts one Codex app server per account, and
|
|
353
354
|
the common already-clean tick has no reason to pay for them.
|
|
354
355
|
|
|
355
356
|
Args:
|
|
356
|
-
read_codex_tier: Returns the
|
|
357
|
+
read_codex_tier: Returns the broker's tier, or None when the broker cannot say.
|
|
357
358
|
codex_clean_at: HEAD SHA where Codex last reported clean, or None.
|
|
358
359
|
head_sha: Current PR HEAD commit SHA.
|
|
359
360
|
|
|
@@ -367,20 +368,22 @@ def _evaluate_codex_clean(
|
|
|
367
368
|
return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
|
|
368
369
|
|
|
369
370
|
|
|
370
|
-
def
|
|
371
|
-
"""Return the installed
|
|
371
|
+
def _codex_account_broker_path() -> Path:
|
|
372
|
+
"""Return the installed account broker beside this shared tree."""
|
|
372
373
|
shared_root = Path(__file__).resolve().parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
|
|
373
|
-
return shared_root.joinpath(*
|
|
374
|
+
return shared_root.joinpath(*ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS)
|
|
374
375
|
|
|
375
376
|
|
|
376
377
|
def _read_codex_tier() -> str | None:
|
|
377
|
-
"""Ask the
|
|
378
|
+
"""Ask the account broker which tier a review runs on, or None when it cannot say."""
|
|
378
379
|
try:
|
|
379
380
|
completed = subprocess.run(
|
|
380
381
|
[
|
|
381
382
|
sys.executable,
|
|
382
|
-
str(
|
|
383
|
-
|
|
383
|
+
str(_codex_account_broker_path()),
|
|
384
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
|
|
385
|
+
"--product",
|
|
386
|
+
"codex",
|
|
384
387
|
],
|
|
385
388
|
capture_output=True,
|
|
386
389
|
text=True,
|
|
@@ -390,9 +393,12 @@ def _read_codex_tier() -> str | None:
|
|
|
390
393
|
answer = json.loads(completed.stdout)
|
|
391
394
|
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
|
392
395
|
return None
|
|
393
|
-
if completed.returncode
|
|
396
|
+
if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
|
|
394
397
|
return None
|
|
395
|
-
|
|
398
|
+
decision = answer.get("decision")
|
|
399
|
+
if not isinstance(decision, dict):
|
|
400
|
+
return None
|
|
401
|
+
tier = decision.get(CODEX_TIER_KEY)
|
|
396
402
|
return tier if isinstance(tier, str) else None
|
|
397
403
|
|
|
398
404
|
|
|
@@ -69,11 +69,12 @@ CODEX_CLEAN_AT_STATE_KEY: str = "codex_clean_at"
|
|
|
69
69
|
CODEX_DOWN_STATE_KEY: str = "codex_down"
|
|
70
70
|
MINIMUM_ABBREVIATED_SHA_LENGTH: int = 7
|
|
71
71
|
SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 3
|
|
72
|
-
|
|
72
|
+
ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
|
|
73
73
|
"scripts",
|
|
74
|
-
"
|
|
74
|
+
"account_broker.py",
|
|
75
75
|
)
|
|
76
|
-
|
|
76
|
+
CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND: str = "choose"
|
|
77
77
|
CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
|
|
78
78
|
CODEX_TIER_KEY: str = "tier"
|
|
79
|
+
ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES: tuple[int, ...] = (0, 3)
|
|
79
80
|
CODEX_TIER_NORMAL: str = "normal"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Fixture-driven tests for the conditional Codex convergence gate.
|
|
2
2
|
|
|
3
3
|
Covers: required-and-clean, required-and-dirty, skipped-by-tier,
|
|
4
|
-
skipped-by-token, skipped-by-down, and the read of the
|
|
4
|
+
skipped-by-token, skipped-by-down, and the read of the account broker's
|
|
5
5
|
``choose`` answer. The required tier comes from ``CODEX_TIER_NORMAL``.
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -117,15 +117,15 @@ def should_pass_when_codex_required_and_clean(
|
|
|
117
117
|
assert "All pre-conditions met" in captured
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def
|
|
120
|
+
def test_should_pass_without_running_the_broker_when_the_clean_stamp_is_on_head(
|
|
121
121
|
monkeypatch: pytest.MonkeyPatch,
|
|
122
122
|
) -> None:
|
|
123
|
-
def
|
|
123
|
+
def _broker_that_must_not_run() -> str | None:
|
|
124
124
|
raise AssertionError(
|
|
125
|
-
"the
|
|
125
|
+
"the account broker must not run when codex_clean_at is on HEAD"
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
-
monkeypatch.setattr(check_convergence, "_read_codex_tier",
|
|
128
|
+
monkeypatch.setattr(check_convergence, "_read_codex_tier", _broker_that_must_not_run)
|
|
129
129
|
|
|
130
130
|
is_passed, detail = check_convergence._evaluate_codex_clean(
|
|
131
131
|
read_codex_tier=check_convergence._read_codex_tier,
|
|
@@ -228,7 +228,7 @@ def should_fail_when_codex_required_and_dirty(
|
|
|
228
228
|
|
|
229
229
|
|
|
230
230
|
@pytest.mark.parametrize("tier_without_room", ALL_TIERS_WITHOUT_ROOM_FOR_REVIEW)
|
|
231
|
-
def
|
|
231
|
+
def test_should_skip_when_the_broker_names_no_account_with_room_for_review(
|
|
232
232
|
tier_without_room: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
233
233
|
) -> None:
|
|
234
234
|
fixture_path = _write_fixture(
|
|
@@ -513,47 +513,86 @@ def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
|
|
|
513
513
|
assert arguments.codex_clean_at == HEAD_SHA
|
|
514
514
|
|
|
515
515
|
|
|
516
|
-
def
|
|
517
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
516
|
+
def _install_fake_broker(
|
|
517
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
|
|
518
518
|
) -> None:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
monkeypatch.setattr(check_convergence, "
|
|
519
|
+
broker_path = tmp_path / "account_broker.py"
|
|
520
|
+
broker_path.write_text(broker_source, encoding="utf-8")
|
|
521
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
522
522
|
|
|
523
523
|
|
|
524
|
-
def
|
|
524
|
+
def test_should_read_normal_tier_from_broker_decision(
|
|
525
525
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
526
526
|
) -> None:
|
|
527
|
-
|
|
527
|
+
broker_path = tmp_path / "account_broker.py"
|
|
528
|
+
broker_path.write_text(
|
|
529
|
+
"import json, sys\n"
|
|
530
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
531
|
+
"print(json.dumps({'decision': {'tier': 'normal', 'reason': 'room'}, 'accounts': []}))\n",
|
|
532
|
+
encoding="utf-8",
|
|
533
|
+
)
|
|
534
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
535
|
+
|
|
536
|
+
assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def test_should_read_wait_tier_from_broker_exit_three(
|
|
540
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
541
|
+
) -> None:
|
|
542
|
+
broker_path = tmp_path / "account_broker.py"
|
|
543
|
+
broker_path.write_text(
|
|
544
|
+
"import json, sys\n"
|
|
545
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
546
|
+
"print(json.dumps({'decision': {'tier': 'wait', 'reason': 'no room'}, 'accounts': []}))\n"
|
|
547
|
+
"sys.exit(3)\n",
|
|
548
|
+
encoding="utf-8",
|
|
549
|
+
)
|
|
550
|
+
monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
|
|
551
|
+
|
|
552
|
+
assert check_convergence._read_codex_tier() == "wait"
|
|
553
|
+
|
|
554
|
+
passed, detail = check_convergence._evaluate_codex_clean(
|
|
555
|
+
read_codex_tier=check_convergence._read_codex_tier,
|
|
556
|
+
codex_clean_at=None,
|
|
557
|
+
head_sha=HEAD_SHA,
|
|
558
|
+
)
|
|
559
|
+
assert passed
|
|
560
|
+
assert "skipped" in detail
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def test_should_read_the_tier_the_broker_chooses(
|
|
564
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
565
|
+
) -> None:
|
|
566
|
+
_install_fake_broker(
|
|
528
567
|
tmp_path,
|
|
529
568
|
monkeypatch,
|
|
530
569
|
"import json, sys\n"
|
|
531
|
-
"assert sys.argv[1:] == ['choose']\n"
|
|
532
|
-
"print(json.dumps({'tier': 'normal', 'account': 'codex-2'}))\n",
|
|
570
|
+
"assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
|
|
571
|
+
"print(json.dumps({'decision': {'tier': 'normal', 'account': 'codex-2'}, 'accounts': []}))\n",
|
|
533
572
|
)
|
|
534
573
|
|
|
535
574
|
assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
|
|
536
575
|
|
|
537
576
|
|
|
538
577
|
@pytest.mark.parametrize(
|
|
539
|
-
"
|
|
578
|
+
"broker_source",
|
|
540
579
|
[
|
|
541
|
-
"import sys\nprint('{\"tier\": \"normal\"}')\nsys.exit(1)\n",
|
|
580
|
+
"import sys\nprint('{\"decision\": {\"tier\": \"normal\"}}')\nsys.exit(1)\n",
|
|
542
581
|
"print('not json')\n",
|
|
543
582
|
"print('[\"normal\"]')\n",
|
|
544
|
-
"print('{\"tier\": 1}')\n",
|
|
583
|
+
"print('{\"decision\": {\"tier\": 1}}')\n",
|
|
545
584
|
],
|
|
546
585
|
)
|
|
547
|
-
def
|
|
548
|
-
|
|
586
|
+
def test_should_read_no_tier_when_the_broker_fails_or_answers_out_of_shape(
|
|
587
|
+
broker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
549
588
|
) -> None:
|
|
550
|
-
|
|
589
|
+
_install_fake_broker(tmp_path, monkeypatch, broker_source)
|
|
551
590
|
|
|
552
591
|
assert check_convergence._read_codex_tier() is None
|
|
553
592
|
|
|
554
593
|
|
|
555
|
-
def
|
|
556
|
-
|
|
594
|
+
def test_should_locate_the_packaged_codex_account_broker() -> None:
|
|
595
|
+
broker_path = check_convergence._codex_account_broker_path()
|
|
557
596
|
|
|
558
|
-
assert
|
|
559
|
-
assert
|
|
597
|
+
assert broker_path.name == "account_broker.py"
|
|
598
|
+
assert broker_path.is_file()
|
package/docs/codex-accounts.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# Codex accounts
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The account broker assigns agent work across the Codex accounts.
|
|
4
|
+
Each account signs in under its own Codex home, and every home
|
|
5
5
|
shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
|
|
6
|
-
A job asks the
|
|
6
|
+
A job asks the broker which account to use.
|
|
7
7
|
|
|
8
8
|
## Pieces
|
|
9
9
|
|
|
10
10
|
| File | What it does |
|
|
11
11
|
|---|---|
|
|
12
|
-
| `scripts/
|
|
12
|
+
| `scripts/account_broker.py` | `choose --product codex` names the account and tier a job runs on; `check --product codex` reports whether any account has room |
|
|
13
|
+
| `scripts/codex_account_choice.py` | `sync` links the shared setup into every account's home; `setup` and `install` write one launcher per account |
|
|
13
14
|
| `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
|
|
14
15
|
| `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
|
|
15
16
|
|
|
@@ -21,8 +22,8 @@ The account roster names the accounts and their order. It comes from the
|
|
|
21
22
|
in `account-launchers.json` under the profiles root. When neither exists, the
|
|
22
23
|
accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
|
|
23
24
|
|
|
24
|
-
`choose
|
|
25
|
-
|
|
25
|
+
The broker's `choose` and `check` commands and the setup commands use that list.
|
|
26
|
+
Each account's home is `~/.codex-profiles/<name>`, or
|
|
26
27
|
`<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
|
|
27
28
|
and is never an account. A name uses letters, digits, hyphens or underscores.
|
|
28
29
|
`main`, `wait` and the Windows device names are refused.
|
|
@@ -83,50 +84,45 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
|
|
|
83
84
|
again restores it. A blank first line empties the roster and moves every launcher
|
|
84
85
|
aside.
|
|
85
86
|
|
|
86
|
-
`check <name>` reads a saved name the same way:
|
|
87
|
-
|
|
88
|
-
```
|
|
89
|
-
python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
|
|
90
|
-
```
|
|
91
|
-
|
|
92
87
|
## Which account a job uses
|
|
93
88
|
|
|
94
89
|
Room is the smaller of an account's two windows: the 5-hour window and the week.
|
|
95
90
|
|
|
96
91
|
| Condition | Answer |
|
|
97
92
|
|---|---|
|
|
98
|
-
|
|
|
99
|
-
| No account over 10%, one or more over 1% | `luna` on the account with the most room
|
|
93
|
+
| One or more accounts with more than 10% left | `normal` on the account with the most room |
|
|
94
|
+
| No account over 10%, one or more over 1% | `luna` on the account with the most room |
|
|
100
95
|
| An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
|
|
101
|
-
| No account over 1% | `wait`, naming the
|
|
102
|
-
|
|
|
96
|
+
| No account over 1% | `wait`, naming the next reset when known |
|
|
97
|
+
| A meter is unread | The account has `meters: null` in `accounts` |
|
|
103
98
|
|
|
104
99
|
```
|
|
105
|
-
python packages/claude-dev-env/scripts/
|
|
106
|
-
{"
|
|
107
|
-
"
|
|
100
|
+
python packages/claude-dev-env/scripts/account_broker.py choose --product codex
|
|
101
|
+
{"decision": {"action": "run", "account": "codex-1", "home": "...\\codex-1",
|
|
102
|
+
"resets_at": null, "reason": "codex-1 has 62% left", "tier": "normal"},
|
|
103
|
+
"accounts": [{"name": "codex-1", "home": "...\\codex-1", "is_main": false,
|
|
104
|
+
"meters": {"session_percent_left": 62.0, "session_resets_at": null,
|
|
105
|
+
"weekly_percent_left": 70.0, "weekly_resets_at": null}}]}
|
|
108
106
|
```
|
|
109
107
|
|
|
110
|
-
A job runs Codex with `CODEX_HOME` set to `
|
|
108
|
+
A job runs Codex with `CODEX_HOME` set to `decision.home`. On `luna`, the job runs
|
|
111
109
|
Luna and polls `check` between steps:
|
|
112
110
|
|
|
113
111
|
```
|
|
114
|
-
python packages/claude-dev-env/scripts/
|
|
112
|
+
python packages/claude-dev-env/scripts/account_broker.py check --product codex
|
|
115
113
|
```
|
|
116
114
|
|
|
117
|
-
`check` exits
|
|
118
|
-
|
|
115
|
+
`check` exits 3 while every account is below its floor, and 0 when an account
|
|
116
|
+
has room. The job stops on 3. `choose` also exits 3 for `wait` and still prints
|
|
117
|
+
the JSON answer.
|
|
119
118
|
|
|
120
119
|
## Shared callers
|
|
121
120
|
|
|
122
|
-
Two scripts under `_shared` run `choose` by path. Each finds the
|
|
123
|
-
`scripts/
|
|
124
|
-
installed, that is `~/.claude/scripts/
|
|
121
|
+
Two scripts under `_shared` run `choose --product codex` by path. Each finds the
|
|
122
|
+
broker at `scripts/account_broker.py` in the directory that holds `_shared`.
|
|
123
|
+
Once installed, that is `~/.claude/scripts/account_broker.py`.
|
|
125
124
|
|
|
126
125
|
| Caller | What it does with the answer |
|
|
127
126
|
|---|---|
|
|
128
|
-
| `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed
|
|
129
|
-
| `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `
|
|
130
|
-
|
|
131
|
-
Pass `--codex-path` when `codex` is off PATH. Without it the picker also tries
|
|
132
|
-
the desktop install path under the user home.
|
|
127
|
+
| `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed broker skips the Codex gate |
|
|
128
|
+
| `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `decision.home` |
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Hook that
|
|
2
|
+
"""Hook that restores poteto-mode after compaction and loads it for helpers.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
6
|
Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke poteto-mode"
|
|
7
7
|
Workflow script helper starts -> "invoke poteto-mode now"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
compact SessionStart, skill invoked before -> "invoke the skill again"
|
|
9
|
+
compact SessionStart, no prior invocation
|
|
10
|
+
or transcript unavailable -> nothing
|
|
11
|
+
user turn, skill invoked then compacted -> "invoke poteto-mode now"
|
|
12
|
+
user turn, skill loaded, never invoked,
|
|
13
|
+
or transcript unavailable -> nothing
|
|
14
|
+
|
|
15
|
+
A session that invoked the skill hears nothing more until a compaction drops it.
|
|
13
16
|
The hook prints its output and stops.
|
|
14
17
|
"""
|
|
15
18
|
|
|
@@ -116,66 +119,92 @@ def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> b
|
|
|
116
119
|
return was_loaded or _invokes_poteto_mode(all_entry_fields)
|
|
117
120
|
|
|
118
121
|
|
|
119
|
-
def
|
|
120
|
-
"""
|
|
122
|
+
def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
|
|
123
|
+
"""Report whether the skill was invoked and whether it remains loaded.
|
|
121
124
|
|
|
122
125
|
::
|
|
123
126
|
|
|
124
|
-
Skill(poteto-mode) ... Read
|
|
125
|
-
/poteto-mode
|
|
126
|
-
Skill(poteto-mode) ... compact_boundary ...
|
|
127
|
-
|
|
127
|
+
Skill(poteto-mode) ... Read -> (True, True)
|
|
128
|
+
/poteto-mode ... compact_boundary -> (True, False)
|
|
129
|
+
Skill(poteto-mode) ... compact_boundary ... Skill(poteto-mode)
|
|
130
|
+
-> (True, True)
|
|
131
|
+
Read ... Edit -> (False, False)
|
|
128
132
|
|
|
129
133
|
Only lines naming the skill or a compaction are parsed, so a long transcript
|
|
130
134
|
reads fast.
|
|
131
135
|
|
|
132
136
|
Args:
|
|
133
137
|
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Whether the skill was invoked at any point and whether it is loaded now.
|
|
134
141
|
"""
|
|
142
|
+
was_invoked = False
|
|
135
143
|
is_loaded = False
|
|
136
144
|
for each_line in all_transcript_lines:
|
|
137
145
|
all_entry_fields = _marker_entry(each_line)
|
|
138
146
|
if all_entry_fields is not None:
|
|
139
147
|
is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
|
|
140
|
-
|
|
148
|
+
was_invoked = was_invoked or is_loaded
|
|
149
|
+
return was_invoked, is_loaded
|
|
150
|
+
|
|
141
151
|
|
|
152
|
+
def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
|
|
153
|
+
"""Return whether the transcript invoked the skill after its last compaction.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
142
157
|
|
|
143
|
-
|
|
158
|
+
Returns:
|
|
159
|
+
Whether the skill is loaded now.
|
|
160
|
+
"""
|
|
161
|
+
return poteto_mode_status(all_transcript_lines)[1]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _status_in_transcript(transcript_path: object) -> tuple[bool, bool]:
|
|
144
165
|
if not isinstance(transcript_path, str):
|
|
145
|
-
return False
|
|
166
|
+
return False, False
|
|
146
167
|
try:
|
|
147
168
|
with open(
|
|
148
169
|
transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
149
170
|
) as transcript:
|
|
150
|
-
return
|
|
171
|
+
return poteto_mode_status(transcript)
|
|
151
172
|
except OSError:
|
|
152
|
-
return False
|
|
173
|
+
return False, False
|
|
153
174
|
|
|
154
175
|
|
|
155
176
|
def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
156
|
-
"""Return
|
|
177
|
+
"""Return a reminder only for helpers or sessions that need the skill restored.
|
|
157
178
|
|
|
158
179
|
::
|
|
159
180
|
|
|
160
|
-
SessionStart,
|
|
181
|
+
SessionStart, compact, skill invoked before -> COMPACTION_REMINDER
|
|
182
|
+
SessionStart, compact, no prior invocation
|
|
183
|
+
or transcript unavailable -> None
|
|
161
184
|
SubagentStart, workflow-subagent -> NOT_LOADED_REMINDER
|
|
162
|
-
UserPromptSubmit,
|
|
163
|
-
UserPromptSubmit,
|
|
185
|
+
UserPromptSubmit, skill invoked then compacted -> NOT_LOADED_REMINDER
|
|
186
|
+
UserPromptSubmit, skill loaded, never invoked,
|
|
187
|
+
or transcript unavailable -> None
|
|
164
188
|
|
|
165
189
|
Args:
|
|
166
190
|
all_hook_fields: The parsed hook input.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The event's reminder, or None when no reminder is needed.
|
|
167
194
|
"""
|
|
168
195
|
event_name = all_hook_fields.get("hook_event_name")
|
|
169
196
|
if event_name == SESSION_START_EVENT_NAME:
|
|
170
|
-
|
|
197
|
+
if all_hook_fields.get("source") != COMPACTION_SOURCE:
|
|
198
|
+
return None
|
|
199
|
+
was_invoked, _ = _status_in_transcript(all_hook_fields.get("transcript_path"))
|
|
200
|
+
return COMPACTION_REMINDER if was_invoked else None
|
|
171
201
|
if event_name == SUBAGENT_START_EVENT_NAME:
|
|
172
202
|
is_workflow_helper = all_hook_fields.get("agent_type") == WORKFLOW_SUBAGENT_TYPE
|
|
173
203
|
return NOT_LOADED_REMINDER if is_workflow_helper else None
|
|
174
204
|
if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
175
205
|
return None
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return NOT_LOADED_REMINDER
|
|
206
|
+
was_invoked, is_loaded = _status_in_transcript(all_hook_fields.get("transcript_path"))
|
|
207
|
+
return NOT_LOADED_REMINDER if was_invoked and not is_loaded else None
|
|
179
208
|
|
|
180
209
|
|
|
181
210
|
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for skill_loaded_reminder. Checks
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks restoration after compaction and helper prompts."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
@@ -167,14 +167,25 @@ class TestCodexSpawn:
|
|
|
167
167
|
|
|
168
168
|
def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
|
|
169
169
|
assert (
|
|
170
|
-
_run_main(
|
|
171
|
-
_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent")
|
|
172
|
-
)
|
|
170
|
+
_run_main(_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent"))
|
|
173
171
|
== ""
|
|
174
172
|
)
|
|
175
173
|
|
|
176
174
|
|
|
177
175
|
class TestIsPotetoModeLoaded:
|
|
176
|
+
def test_status_without_a_skill_call_reports_no_invocation_or_load(self) -> None:
|
|
177
|
+
assert reminder.poteto_mode_status(iter([READ_CALL_LINE])) == (False, False)
|
|
178
|
+
|
|
179
|
+
def test_status_keeps_invocation_history_after_compaction(self) -> None:
|
|
180
|
+
assert reminder.poteto_mode_status(
|
|
181
|
+
iter([SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE])
|
|
182
|
+
) == (True, False)
|
|
183
|
+
|
|
184
|
+
def test_status_reports_the_skill_loaded_again_after_compaction(self) -> None:
|
|
185
|
+
assert reminder.poteto_mode_status(
|
|
186
|
+
iter([SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE])
|
|
187
|
+
) == (True, True)
|
|
188
|
+
|
|
178
189
|
def test_a_skill_call_loads_it(self) -> None:
|
|
179
190
|
assert reminder.is_poteto_mode_loaded([READ_CALL_LINE, SKILL_CALL_LINE, READ_CALL_LINE])
|
|
180
191
|
|
|
@@ -210,35 +221,106 @@ class TestIsPotetoModeLoaded:
|
|
|
210
221
|
|
|
211
222
|
|
|
212
223
|
class TestReminderFor:
|
|
213
|
-
def
|
|
224
|
+
def test_a_user_turn_in_a_session_that_never_loaded_the_skill_prints_nothing(
|
|
214
225
|
self, tmp_path: Path
|
|
215
226
|
) -> None:
|
|
216
227
|
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
228
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
229
|
+
|
|
230
|
+
def test_a_user_turn_after_the_skill_and_compaction_gets_the_reminder(
|
|
231
|
+
self, tmp_path: Path
|
|
232
|
+
) -> None:
|
|
233
|
+
transcript_path = _write_transcript(
|
|
234
|
+
tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
|
|
235
|
+
)
|
|
217
236
|
emitted = json.loads(_run_main(_user_turn(transcript_path)))
|
|
218
237
|
hook_output = emitted["hookSpecificOutput"]
|
|
219
238
|
assert hook_output["hookEventName"] == "UserPromptSubmit"
|
|
220
239
|
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
221
240
|
assert "poteto-mode" in NOT_LOADED_REMINDER
|
|
222
241
|
|
|
242
|
+
def test_a_user_turn_after_the_skill_is_reloaded_prints_nothing(self, tmp_path: Path) -> None:
|
|
243
|
+
transcript_path = _write_transcript(
|
|
244
|
+
tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
|
|
245
|
+
)
|
|
246
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
247
|
+
|
|
223
248
|
def test_a_user_turn_in_a_session_that_loaded_the_skill_prints_nothing(
|
|
224
249
|
self, tmp_path: Path
|
|
225
250
|
) -> None:
|
|
226
251
|
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
227
252
|
assert _run_main(_user_turn(transcript_path)) == ""
|
|
228
253
|
|
|
229
|
-
def
|
|
230
|
-
assert (
|
|
231
|
-
reminder.reminder_for(_user_turn(tmp_path / "not-written-yet.jsonl"))
|
|
232
|
-
== NOT_LOADED_REMINDER
|
|
233
|
-
)
|
|
254
|
+
def test_a_user_turn_with_no_transcript_yet_prints_nothing(self, tmp_path: Path) -> None:
|
|
255
|
+
assert _run_main(_user_turn(tmp_path / "not-written-yet.jsonl")) == ""
|
|
234
256
|
|
|
235
|
-
def
|
|
236
|
-
|
|
257
|
+
def test_session_start_after_compaction_tells_a_prior_user_to_invoke_it_again(
|
|
258
|
+
self, tmp_path: Path
|
|
259
|
+
) -> None:
|
|
260
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
261
|
+
emitted = json.loads(
|
|
262
|
+
_run_main(
|
|
263
|
+
{
|
|
264
|
+
"hook_event_name": "SessionStart",
|
|
265
|
+
"source": "compact",
|
|
266
|
+
"transcript_path": str(transcript_path),
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
)
|
|
237
270
|
hook_output = emitted["hookSpecificOutput"]
|
|
238
271
|
assert hook_output["hookEventName"] == "SessionStart"
|
|
239
272
|
assert hook_output["additionalContext"] == COMPACTION_REMINDER
|
|
240
273
|
assert "poteto-mode" in COMPACTION_REMINDER
|
|
241
274
|
|
|
275
|
+
def test_session_start_after_a_written_boundary_reminds_a_prior_user(
|
|
276
|
+
self, tmp_path: Path
|
|
277
|
+
) -> None:
|
|
278
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE])
|
|
279
|
+
assert (
|
|
280
|
+
reminder.reminder_for(
|
|
281
|
+
{
|
|
282
|
+
"hook_event_name": "SessionStart",
|
|
283
|
+
"source": "compact",
|
|
284
|
+
"transcript_path": str(transcript_path),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
== COMPACTION_REMINDER
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def test_session_start_after_compaction_without_a_prior_skill_call_prints_nothing(
|
|
291
|
+
self, tmp_path: Path
|
|
292
|
+
) -> None:
|
|
293
|
+
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
294
|
+
assert (
|
|
295
|
+
_run_main(
|
|
296
|
+
{
|
|
297
|
+
"hook_event_name": "SessionStart",
|
|
298
|
+
"source": "compact",
|
|
299
|
+
"transcript_path": str(transcript_path),
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
== ""
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def test_session_start_after_compaction_without_a_transcript_path_prints_nothing(
|
|
306
|
+
self,
|
|
307
|
+
) -> None:
|
|
308
|
+
assert _run_main({"hook_event_name": "SessionStart", "source": "compact"}) == ""
|
|
309
|
+
|
|
310
|
+
def test_session_start_after_compaction_with_an_unreadable_transcript_prints_nothing(
|
|
311
|
+
self, tmp_path: Path
|
|
312
|
+
) -> None:
|
|
313
|
+
assert (
|
|
314
|
+
_run_main(
|
|
315
|
+
{
|
|
316
|
+
"hook_event_name": "SessionStart",
|
|
317
|
+
"source": "compact",
|
|
318
|
+
"transcript_path": str(tmp_path / "not-written-yet.jsonl"),
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
== ""
|
|
322
|
+
)
|
|
323
|
+
|
|
242
324
|
def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
|
|
243
325
|
assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
|
|
244
326
|
|