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.
@@ -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 Codex account picker
33
- (``scripts/codex_account_choice.py choose``) answers the ``normal`` tier, so
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 picker, the codex token, or ``codex_down`` never blocks ready.
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
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS,
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
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
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 (picker never runs)
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 picker
352
- stays behind it: the picker starts one Codex app server per account, and
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 picker's tier, or None when the picker cannot say.
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 _codex_account_picker_path() -> Path:
371
- """Return the installed Codex account picker beside this shared tree."""
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(*ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS)
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 Codex account picker which tier a review runs on, or None when it cannot say."""
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(_codex_account_picker_path()),
383
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
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 != 0 or not isinstance(answer, dict):
396
+ if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
394
397
  return None
395
- tier = answer.get(CODEX_TIER_KEY)
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
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS: tuple[str, ...] = (
72
+ ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
73
73
  "scripts",
74
- "codex_account_choice.py",
74
+ "account_broker.py",
75
75
  )
76
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND: str = "choose"
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 Codex account picker's
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 should_pass_without_running_the_picker_when_the_clean_stamp_is_on_head(
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 _picker_that_must_not_run() -> str | None:
123
+ def _broker_that_must_not_run() -> str | None:
124
124
  raise AssertionError(
125
- "the Codex account picker must not run when codex_clean_at is on HEAD"
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", _picker_that_must_not_run)
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 should_skip_when_the_picker_names_no_account_with_room_for_review(
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 _install_fake_picker(
517
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, picker_source: str
516
+ def _install_fake_broker(
517
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
518
518
  ) -> None:
519
- picker_path = tmp_path / "codex_account_choice.py"
520
- picker_path.write_text(picker_source, encoding="utf-8")
521
- monkeypatch.setattr(check_convergence, "_codex_account_picker_path", lambda: picker_path)
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 should_read_the_tier_the_picker_chooses(
524
+ def test_should_read_normal_tier_from_broker_decision(
525
525
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
526
526
  ) -> None:
527
- _install_fake_picker(
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
- "picker_source",
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 should_read_no_tier_when_the_picker_fails_or_answers_out_of_shape(
548
- picker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
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
- _install_fake_picker(tmp_path, monkeypatch, picker_source)
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 should_locate_the_packaged_codex_account_picker() -> None:
556
- picker_path = check_convergence._codex_account_picker_path()
594
+ def test_should_locate_the_packaged_codex_account_broker() -> None:
595
+ broker_path = check_convergence._codex_account_broker_path()
557
596
 
558
- assert picker_path.name == "codex_account_choice.py"
559
- assert picker_path.is_file()
597
+ assert broker_path.name == "account_broker.py"
598
+ assert broker_path.is_file()
@@ -1,15 +1,16 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across the Codex accounts
4
- on one machine. Each account signs in under its own Codex home, and every home
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 picker which account to use.
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/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
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`, `sync` and `check` all work on that list, in that order. `check`
25
- accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
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
- | First account in order with more than 10% left | `normal` on that account |
99
- | No account over 10%, one or more over 1% | `luna` on the account with the most room, `stop_below_percent` 1 |
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 account whose blocking windows reset first |
102
- | Account not signed in, or its meter unread | skipped, with the reason in `accounts` |
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/codex_account_choice.py choose
106
- {"tier": "normal", "account": "codex-1", "codex_home": "...\\codex-1", "percent_left": 62.0,
107
- "stop_below_percent": null, "reason": "codex-1 has 62% left", "accounts": [...]}
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 `codex_home`. On `luna`, the job runs
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/codex_account_choice.py check codex-2 --floor 1
112
+ python packages/claude-dev-env/scripts/account_broker.py check --product codex
115
113
  ```
116
114
 
117
- `check` exits 0 while the account has more than the floor left, and 3 once it has
118
- not or its meter is unread. The job stops on 3.
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 picker at
123
- `scripts/codex_account_choice.py` in the directory that holds `_shared`. Once
124
- installed, that is `~/.claude/scripts/codex_account_choice.py`.
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 picker skips the Codex gate |
129
- | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `codex_home` |
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 keeps poteto-mode loaded in every session, subagent and workflow helper.
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
- context compacted mid-run -> "invoke poteto-mode again"
9
- user turn, skill not loaded since compacting -> "invoke poteto-mode now"
10
- user turn, skill already loaded -> nothing
11
-
12
- A session that loaded the skill hears nothing more until a compaction drops it.
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 is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
120
- """Return True when the transcript invoked the skill after its last compaction.
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 ... Edit -> True
125
- /poteto-mode typed as a command ... Read -> True
126
- Skill(poteto-mode) ... compact_boundary ... Read -> False
127
- Read ... Edit -> False
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
- return is_loaded
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
- def _is_loaded_in_transcript(transcript_path: object) -> bool:
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 is_poteto_mode_loaded(transcript)
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 the reminder a session event carries, or None when the skill is in view.
177
+ """Return a reminder only for helpers or sessions that need the skill restored.
157
178
 
158
179
  ::
159
180
 
160
- SessionStart, source compact -> COMPACTION_REMINDER
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, transcript lacks the skill -> NOT_LOADED_REMINDER
163
- UserPromptSubmit, transcript loaded the skill -> None
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
- return COMPACTION_REMINDER if all_hook_fields.get("source") == COMPACTION_SOURCE else None
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
- if _is_loaded_in_transcript(all_hook_fields.get("transcript_path")):
177
- return None
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 every fresh context gets poteto-mode, and loaded ones hear nothing."""
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 test_a_user_turn_in_a_session_that_never_loaded_the_skill_gets_the_reminder(
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 test_a_user_turn_with_no_transcript_yet_gets_the_reminder(self, tmp_path: Path) -> None:
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 test_session_start_after_compaction_tells_the_session_to_invoke_it_again(self) -> None:
236
- emitted = json.loads(_run_main({"hook_event_name": "SessionStart", "source": "compact"}))
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.38.2",
3
+ "version": "8.39.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {