claude-dev-env 8.43.5 → 8.44.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.
Files changed (36) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.test.mjs +1 -1
  6. package/docs/second-claude-account.md +11 -13
  7. package/hooks/blocking/reply_length_gate.py +43 -11
  8. package/hooks/blocking/test_reply_length_gate.py +55 -0
  9. package/hooks/hooks.json +1 -1
  10. package/hooks/hooks_constants/reply_length_gate_constants.py +11 -0
  11. package/package.json +1 -1
  12. package/scripts/_code_review_test_support.py +29 -39
  13. package/scripts/claude_account_worker.py +46 -297
  14. package/scripts/claude_account_worker_process.py +18 -154
  15. package/scripts/claude_account_worker_report.py +8 -4
  16. package/scripts/claude_chain_usage.py +7 -271
  17. package/scripts/codec_forwarding_test_support.py +11 -65
  18. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  19. package/scripts/invoke_code_review.py +34 -56
  20. package/scripts/resolve_worker_spawn.py +40 -72
  21. package/scripts/test_account_broker_guard.py +0 -4
  22. package/scripts/test_claude_account_worker.py +105 -350
  23. package/scripts/test_claude_chain_usage.py +50 -510
  24. package/scripts/test_dispatcher_profile_import.py +5 -9
  25. package/scripts/test_invoke_code_review.py +7 -6
  26. package/scripts/test_invoke_code_review_chain.py +26 -0
  27. package/scripts/test_invoke_code_review_cli.py +8 -8
  28. package/scripts/test_invoke_code_review_codec.py +3 -11
  29. package/scripts/test_invoke_code_review_contract.py +5 -1
  30. package/scripts/test_resolve_worker_spawn.py +118 -265
  31. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  32. package/scripts/tests/test_code_review_constants.py +2 -2
  33. package/scripts/claude_account_choice.py +0 -422
  34. package/scripts/claude_chain_runner.py +0 -1289
  35. package/scripts/test_claude_account_choice.py +0 -355
  36. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -138,8 +138,8 @@ Drift signals and the per-host re-spawn / re-bind steps: [`reference/lifecycle.m
138
138
 
139
139
  ## CLI chain
140
140
 
141
- The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" [--routing-mode usage_ranked|ordered_account] -- <claude args...>`.
142
- Modes and failover, the tier-to-alias table, brief piping, and `--resume` session handling: [`reference/cli-chain.md`](reference/cli-chain.md).
141
+ The shared runner is `python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <path> -- claude <args...>`.
142
+ Account choice, failover, the tier-to-alias table, brief piping, and `--resume` session handling: [`reference/cli-chain.md`](reference/cli-chain.md).
143
143
 
144
144
  **Third-party host:** the primary bind and consult path; the walk order and fail-closed rule live in [`reference/third-party-bind.md`](reference/third-party-bind.md).
145
145
 
@@ -1,18 +1,15 @@
1
1
  # CLI Claude-chain
2
2
 
3
3
  Detail behind the `## CLI chain` section of [`advisor-protocol.md`](../advisor-protocol.md).
4
- The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" [--routing-mode usage_ranked|ordered_account] -- <claude args...>`.
4
+ The shared runner is `python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <path> -- claude <args...>`.
5
5
 
6
- ## Modes
6
+ ## Account choice
7
7
 
8
- | Mode | Flag | Walk order | Failover |
9
- |---|---|---|---|
10
- | Usage-ranked (default) | `--routing-mode usage_ranked` or omit the flag | Highest weekly remaining first (`claude_chain_usage` / usage-pause OAuth probe) | Usage-limit signature only |
11
- | Ordered-account | `--routing-mode ordered_account` | Config list order in `~/.claude/claude-chain.json` | Usage-limit signature only; auth / timeout / config / other process errors → `advisor_blocked` |
12
-
13
- **Root advisor bind and consult** (the third-party host path): ordered-account mode. A non-usage failure terminates with `terminal_status=advisor_blocked`, exit code 4 on the CLI.
14
-
15
- **General chain calls** (non-root automation): keep the default usage-ranked mode.
8
+ The broker applies the main account guard and chooses an eligible extra account
9
+ by remaining usage. On a usage-limit response, it tries another account. If no
10
+ account has room, it reports a reset time and exits with code 3. A non-usage
11
+ Claude failure has `JobOutcome.status == "advisor_blocked"`; the CLI exits with
12
+ code 4. The report path receives the broker's decision and attempt events.
16
13
 
17
14
  ## Tier-to-alias map
18
15
 
@@ -38,8 +35,8 @@ Drop the file once the consult completes.
38
35
 
39
36
  ## Session resume
40
37
 
41
- Read the `session_id` out of the first call's JSON events.
38
+ Read the `session_id` out of the first call's JSON events or `JobOutcome.session_id`.
42
39
  Pass it to `-p --resume <session_id> --output-format json` on every later consult — `-p` stays on the resume call too, since it is still a non-interactive invocation.
43
- A session store belongs to the binary and account that minted it, so after a usage-limit failover to the next binary a `--resume` against it can fail.
40
+ A session store belongs to the account that minted it. The broker keeps account affinity when that account has room. If the account reaches a usage limit, a `--resume` on another account can fail.
44
41
  Treat that failure as starting over.
45
42
  Resend the charter plus a compact recap of the consults since the last one, capture the new `session_id` the fresh call returns, and continue from there.
@@ -22,6 +22,6 @@ Every other consumer reaches it by message alone; spawn, respawn, and shutdown b
22
22
 
23
23
  The orchestrating session owns the CLI advisor bind for the whole run: first bind, re-bind on drift or lost `session_id`, and fail-closed report when the bound path cannot serve.
24
24
 
25
- **Re-bind on drift.** If a reply shows a stale picture, the task pivots, or `--resume` fails after a usage-limit failover (session stores are per binary/account), re-bind Opus through `claude_chain_runner.py` with the charter plus a compact recap of consults so far.
25
+ **Re-bind on drift.** If a reply shows a stale picture, the task pivots, or `--resume` fails after a usage-limit failover, re-bind Opus through `python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <path> -- claude <args...>` with the charter plus a compact recap of consults so far.
26
26
  Capture the new `session_id`, and log a fresh Opus walk with `result: "cli"` on success, then Astra when that rung is open. An Astra re-bind uses `codex_astra_advisor.py --bind --enable-astra` with the same charter and recap, and records `result: "codex"` on success.
27
27
  Executors keep reporting to the orchestrating session; advisor binding stays with that session alone.
@@ -22,15 +22,15 @@ When the Astra flag is off, follow the Claude-chain steps below.
22
22
  3. **CLI bind (primary path):** for Opus, pipe a charter file into:
23
23
 
24
24
  ```
25
- python "$HOME/.claude/scripts/claude_chain_runner.py" --routing-mode ordered_account -- -p --model <alias> --effort <effort> --output-format json
25
+ python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <path> -- claude -p --model <alias> --effort <effort> --output-format json
26
26
  ```
27
27
 
28
28
  Use `--model opus --effort` with the value of `ADVISOR_EFFORT` (default `xhigh`) on Opus.
29
29
  User-facing wording follows [`rules/asd-ste100-language.md`](../../../rules/asd-ste100-language.md).
30
- A root advisor bind uses `--routing-mode ordered_account`. Walk order, failover, and the `advisor_blocked` terminal status are in [`cli-chain.md`](cli-chain.md).
30
+ Account choice, failover, and the `advisor_blocked` status are in [`cli-chain.md`](cli-chain.md).
31
31
  4. Stop at the first successful bind.
32
32
  Record `{tier, result: "cli"}` for Opus or `{tier: "Astra", result: "codex"}` for the Astra helper, and set `selected_tier` to that tier.
33
- Persist `session_id` from the JSON events (any event carries it; the runner also surfaces it on `ChainInvocationOutcome.session_id`; reply text is the `type == "result"` event's `.result` field).
33
+ Persist `session_id` from the JSON events or `JobOutcome.session_id`; reply text is the `type == "result"` event's `.result` field.
34
34
  Run every bind and every later consult with cwd set to the repo root the work is for. Claude sessions are project-scoped by working directory.
35
35
  5. **Fail closed:** when every candidate fails (chain exhausted, `advisor_blocked`, or model unavailable), set `selected_tier = null` and a `fallback_reason`, report that the advisor is unreachable, and **stop**. ENDORSE / CORRECTION / PLAN / STOP come only from a bound advisor.
36
36
  6. Assemble and paste each executor's Advisor block from [`advisor-block.md`](advisor-block.md). Executors report to the orchestrating session; that session consults the bound advisor and relays the four-signal reply.
@@ -216,7 +216,7 @@ test('core includeDirectories ships _shared and scripts for advisor protocol and
216
216
  );
217
217
  assert.ok(
218
218
  CORE_INCLUDE_DIRECTORIES.includes('scripts'),
219
- 'scripts must ship with --only core so claude_chain_runner.py is available for advisor CLI fallback',
219
+ 'scripts must ship with --only core so account_broker.py is available for advisor CLI fallback',
220
220
  );
221
221
  });
222
222
 
@@ -9,12 +9,12 @@ keeps its own sign-in and history.
9
9
  | File | What it does |
10
10
  |---|---|
11
11
  | `scripts/claude_account_profile.py` | Links each shared entry of `~/.claude` into the profile `~/.claude-profiles/ev`, moves stale copies into `.replaced/<time>/`, and writes the `claude-ev.cmd` launcher into `~/.local/bin` |
12
- | `scripts/claude_account_choice.py` | Reads both accounts' 5-hour and weekly meters and prints which account a job runs on |
13
- | `scripts/claude_account_worker.py` | Runs one headless Claude worker using the account chosen by the picker |
14
- | `scripts/claude_account_worker_process.py` | Launches the worker process, feeds it the brief, and kills it on timeout |
12
+ | `scripts/account_broker.py` | Reads the account roster and usage meters, chooses an account, and runs Claude with that account's home |
13
+ | `scripts/claude_account_worker.py` | Runs one headless Claude worker through the broker |
14
+ | `scripts/claude_account_worker_process.py` | Sends the brief to the broker and measures the run |
15
15
  | `scripts/claude_account_worker_report.py` | Builds and writes the JSON report a worker leaves behind |
16
16
  | `scripts/dev_env_scripts_constants/claude_account_worker_constants.py` | The worker's flag names, defaults, exit codes, and report keys |
17
- | `scripts/dev_env_scripts_constants/claude_account_constants.py` | The profile name, the entries that stay per account, and the picker thresholds |
17
+ | `scripts/dev_env_scripts_constants/claude_account_constants.py` | The profile name, the entries that stay per account, and the account thresholds |
18
18
 
19
19
  The launcher sets `CLAUDE_CONFIG_DIR` to the profile and passes every argument to
20
20
  `claude`, so `claude-ev -p "..."` runs like `claude -p "..."` on the second
@@ -43,19 +43,17 @@ to spend leftover usage that expires soon.
43
43
  |---|---|
44
44
  | Main week resets within 24 hours, main under 90% of its week, main under 50% of its 5-hour window | main |
45
45
  | Otherwise, second under 95% of its week and under 90% of its 5-hour window | second |
46
- | Second meter unreadable | second, whose own run refreshes the sign-in |
46
+ | Second meter unreadable | wait until the meter can be read |
47
47
  | Otherwise | wait, with the next reset time |
48
48
 
49
49
  An unreadable main meter never picks main.
50
50
 
51
51
  ```
52
- python packages/claude-dev-env/scripts/claude_account_choice.py
53
- {"account": "second", "config_dir": "C:\\Users\\me\\.claude-profiles\\ev", "reason": "...",
54
- "meters": {"main": {"session_used_percent": 12.0, "session_resets_at": "...",
55
- "weekly_used_percent": 34.0, "weekly_resets_at": "..."},
56
- "second": null}}
52
+ python packages/claude-dev-env/scripts/account_broker.py choose --product claude
57
53
  ```
58
54
 
59
- The `meters` object carries each account's used percent and reset time for the
60
- 5-hour window and the week. An account whose meter could not be read shows `null`.
61
- A usage report reads both accounts from this one call.
55
+ The JSON output has a `decision` object and an `accounts` list. Each account's
56
+ `meters` holds its remaining percent and reset time for the 5-hour window and
57
+ the week. An unreadable meter appears as `null`. A wait decision has
58
+ `action: "wait"` and a `resets_at` timestamp. The broker exits with code 3 on
59
+ wait. The worker writes the same reset time to its report.
@@ -18,6 +18,10 @@ It denies a banned word or phrase, matched whole and case-insensitive::
18
18
  The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``,
19
19
  or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
20
20
  list there, it uses ALL_DEFAULT_BANNED_WORDS.
21
+
22
+ The banned-word check also reads every text field of a decision card.
23
+ A decision card gets no length check.
24
+
21
25
  Each non-empty line counts as its own sentence, so a list counts one
22
26
  sentence per item. URLs, markdown link targets, inline code spans, and
23
27
  fenced blocks carry no words.
@@ -52,8 +56,10 @@ from hooks_constants.reply_length_gate_constants import (
52
56
  BANNED_WORDS_JSON_KEY,
53
57
  BANNED_WORDS_PATH_ENV_VAR,
54
58
  BLOCK_EXIT_CODE,
59
+ CARD_TEXT_SEPARATOR,
55
60
  CLAUDE_HOME_DIRECTORY_NAME,
56
61
  CONFIG_FILE_ENCODING,
62
+ DECISION_CARD_TOOL_NAME,
57
63
  FENCED_BLOCK_PATTERN,
58
64
  HOOK_EVENT_NAME,
59
65
  INLINE_CODE_PATTERN,
@@ -163,26 +169,52 @@ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) ->
163
169
  return None
164
170
 
165
171
 
172
+ def all_card_texts(card_value: object) -> list[str]:
173
+ """Collect every string inside a decision card's input, in order."""
174
+ if isinstance(card_value, str):
175
+ return [card_value]
176
+ if isinstance(card_value, dict):
177
+ return [
178
+ each_text
179
+ for each_value in card_value.values()
180
+ for each_text in all_card_texts(each_value)
181
+ ]
182
+ if isinstance(card_value, list):
183
+ return [each_text for each_value in card_value for each_text in all_card_texts(each_value)]
184
+ return []
185
+
186
+
187
+ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
188
+ """Return the deny reason and the checked text for one call, or None when it passes."""
189
+ if tool_name == DECISION_CARD_TOOL_NAME:
190
+ card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
191
+ card_violation = banned_word_violation(card_text, configured_banned_words())
192
+ return None if card_violation is None else (card_violation, card_text)
193
+ if tool_name not in ALL_CHECKED_TOOL_NAMES:
194
+ return None
195
+ reply_text = all_tool_input.get(TEXT_KEY)
196
+ if not isinstance(reply_text, str):
197
+ return None
198
+ violation = (
199
+ length_violation(reply_text)
200
+ or unlinked_pull_request_violation(reply_text)
201
+ or banned_word_violation(reply_text, configured_banned_words())
202
+ )
203
+ return None if violation is None else (violation, reply_text)
204
+
205
+
166
206
  def main() -> int:
167
207
  hook_input = read_hook_input_dictionary_from_stdin()
168
208
  if hook_input is None:
169
209
  return ALLOW_EXIT_CODE
170
210
  tool_name = hook_input.get(TOOL_NAME_KEY)
171
- if tool_name not in ALL_CHECKED_TOOL_NAMES:
172
- return ALLOW_EXIT_CODE
173
211
  tool_input = hook_input.get(TOOL_INPUT_KEY)
174
212
  if not isinstance(tool_input, dict):
175
213
  return ALLOW_EXIT_CODE
176
- reply_text = tool_input.get(TEXT_KEY)
177
- if not isinstance(reply_text, str):
178
- return ALLOW_EXIT_CODE
179
- violation = (
180
- length_violation(reply_text)
181
- or unlinked_pull_request_violation(reply_text)
182
- or banned_word_violation(reply_text, configured_banned_words())
183
- )
184
- if violation is None:
214
+ checked = tool_violation(tool_name, tool_input)
215
+ if checked is None:
185
216
  return ALLOW_EXIT_CODE
217
+ violation, reply_text = checked
186
218
  block_reason = violation + RETRY_INSTRUCTION
187
219
  log_hook_block(
188
220
  Path(__file__).name,
@@ -322,3 +322,58 @@ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
322
322
  config_path.write_text("{not json", encoding="utf-8")
323
323
  exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
324
324
  assert exit_code == 2
325
+
326
+
327
+ DECISION_TOOL_NAME = "mcp__hearthbot__ask_decision"
328
+
329
+
330
+ def test_should_deny_a_decision_card_that_hedges_in_an_option(
331
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
332
+ ) -> None:
333
+ exit_code, stderr_text = run_gate(
334
+ monkeypatch,
335
+ capsys,
336
+ DECISION_TOOL_NAME,
337
+ {
338
+ "question": "Exclude the call button?",
339
+ "context": "Crops show the call button flagged in 7 themes.",
340
+ "options": [
341
+ {"label": "Exclude it", "consequence": "Three themes probably pass."},
342
+ {"label": "Leave it", "consequence": "Three themes keep a warning."},
343
+ ],
344
+ "recommended": 0,
345
+ },
346
+ )
347
+ assert exit_code == 2
348
+ assert '"probably"' in stderr_text
349
+
350
+
351
+ def test_should_allow_a_long_decision_card_with_no_hedge(
352
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
353
+ ) -> None:
354
+ exit_code, stderr_text = run_gate(
355
+ monkeypatch,
356
+ capsys,
357
+ DECISION_TOOL_NAME,
358
+ {
359
+ "question": "Exclude the call button?",
360
+ "context": " ".join([FIFTEEN_WORD_SENTENCE] * 5),
361
+ "options": [
362
+ {"label": "Exclude it", "consequence": SIXTEEN_WORD_SENTENCE},
363
+ {"label": "Leave it", "consequence": "Three themes keep a warning."},
364
+ ],
365
+ "recommended": 0,
366
+ },
367
+ )
368
+ assert (exit_code, stderr_text) == (0, "")
369
+
370
+
371
+ @pytest.mark.parametrize(
372
+ "reply_text",
373
+ ["It maybe passed.", "Perhaps it passed.", "It might be the cache.", "I am not sure it passed."],
374
+ )
375
+ def test_should_deny_the_hedges_the_default_list_adds(
376
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
377
+ ) -> None:
378
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
379
+ assert exit_code == 2
package/hooks/hooks.json CHANGED
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  {
86
- "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message",
86
+ "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision",
87
87
  "hooks": [
88
88
  {
89
89
  "type": "command",
@@ -5,6 +5,8 @@ import re
5
5
  MAXIMUM_SENTENCE_COUNT = 3
6
6
  MAXIMUM_WORDS_PER_SENTENCE = 15
7
7
  ALL_CHECKED_TOOL_NAMES = frozenset({"mcp__hearthbot__reply", "mcp__hearthbot__post_message"})
8
+ DECISION_CARD_TOOL_NAME = "mcp__hearthbot__ask_decision"
9
+ CARD_TEXT_SEPARATOR = "\n"
8
10
  TOOL_NAME_KEY = "tool_name"
9
11
  TOOL_INPUT_KEY = "tool_input"
10
12
  TEXT_KEY = "text"
@@ -45,6 +47,15 @@ ALL_DEFAULT_BANNED_WORDS = (
45
47
  "suspect",
46
48
  "guess",
47
49
  "my theory",
50
+ "maybe",
51
+ "perhaps",
52
+ "presumably",
53
+ "might be",
54
+ "may be",
55
+ "appears to",
56
+ "i believe",
57
+ "not sure",
58
+ "unsure",
48
59
  )
49
60
  BANNED_WORD_PART_SEPARATOR = r"\s+"
50
61
  BANNED_WORD_PATTERN_TEMPLATE = r"(?<![A-Za-z0-9]){word_pattern}(?![A-Za-z0-9])"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.43.5",
3
+ "version": "8.44.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,13 +24,8 @@ _SCRIPTS_DIRECTORY = str(Path(__file__).resolve().parent)
24
24
  if _SCRIPTS_DIRECTORY not in sys.path:
25
25
  sys.path.insert(0, _SCRIPTS_DIRECTORY)
26
26
 
27
- import claude_chain_runner as chain_runner # noqa: E402
28
27
  import invoke_code_review as invoker # noqa: E402
29
- from claude_chain_runner import ChainAttempt, ChainInvocationOutcome # noqa: E402
30
- from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
31
- TERMINAL_STATUS_CHAIN_EXHAUSTED,
32
- TERMINAL_STATUS_SERVED,
33
- )
28
+ from dev_env_scripts_constants.account_broker_constants import JobOutcome, Product, WAIT_EXIT_CODE
34
29
  from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
35
30
  CLI_SESSION_MODEL_FLAG,
36
31
  CODE_REVIEW_MODEL_ALIAS,
@@ -111,27 +106,29 @@ def claude_served(
111
106
  *,
112
107
  returncode: int = FIXTURE_CHAIN_RETURNCODE,
113
108
  stdout: str = FIXTURE_CHAIN_STDOUT,
114
- ) -> ChainInvocationOutcome:
115
- return ChainInvocationOutcome(
116
- served_command=FIXTURE_SERVED_COMMAND,
109
+ ) -> JobOutcome:
110
+ return JobOutcome(
117
111
  returncode=returncode,
118
112
  stdout=stdout,
119
113
  stderr="",
120
- attempts=(ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),),
121
- terminal_status=TERMINAL_STATUS_SERVED,
114
+ account_name=FIXTURE_SERVED_COMMAND,
115
+ attempts=((FIXTURE_SERVED_COMMAND, "served"),),
116
+ status="served" if returncode == 0 else "advisor_blocked",
117
+ session_id=None,
118
+ wait_reset_at=None,
122
119
  )
123
120
 
124
121
 
125
- def claude_failed() -> ChainInvocationOutcome:
126
- return ChainInvocationOutcome(
127
- served_command=None,
128
- returncode=FIXTURE_FAILED_RETURNCODE,
122
+ def claude_failed() -> JobOutcome:
123
+ return JobOutcome(
124
+ returncode=WAIT_EXIT_CODE,
129
125
  stdout="",
130
- stderr="chain exhausted",
131
- attempts=(
132
- ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="usage_limited"),
133
- ),
134
- terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
126
+ stderr="",
127
+ account_name=None,
128
+ attempts=((FIXTURE_SERVED_COMMAND, "usage_limited"),),
129
+ status="exhausted",
130
+ session_id=None,
131
+ wait_reset_at=None,
135
132
  )
136
133
 
137
134
 
@@ -179,7 +176,7 @@ class SeamCallLog:
179
176
  @dataclass
180
177
  class _SeamConfiguration:
181
178
  host_profile: str
182
- claude_outcome: ChainInvocationOutcome | BaseException | None
179
+ claude_outcome: JobOutcome | BaseException | None
183
180
  should_dirty_tree_on_chain: bool
184
181
  working_directory: Path | None
185
182
 
@@ -220,23 +217,21 @@ def _build_host_profile_seam(
220
217
 
221
218
  def _build_claude_seam(
222
219
  call_log: SeamCallLog, configuration: _SeamConfiguration
223
- ) -> Callable[..., ChainInvocationOutcome]:
220
+ ) -> Callable[..., JobOutcome]:
224
221
  def fake_claude(
225
- all_claude_arguments: list[str], *, timeout_seconds: int
226
- ) -> ChainInvocationOutcome:
222
+ product: Product, all_claude_arguments: list[str], **options: object
223
+ ) -> JobOutcome:
224
+ assert product is Product.CLAUDE
227
225
  call_log.claude_calls += 1
228
- call_log.claude_arguments = list(all_claude_arguments)
229
- chain_runner.chain_subprocess_runner(
230
- ["claude", *all_claude_arguments],
231
- capture_output=True,
232
- text=True,
233
- timeout=timeout_seconds,
234
- check=False,
235
- )
226
+ call_log.claude_arguments = list(all_claude_arguments[1:])
227
+ call_log.is_stdin_empty = options.get("stdin_text") == ""
228
+ if options.get("cwd") is not None:
229
+ call_log.claude_working_directory = Path(options["cwd"])
230
+ call_log.all_observed_working_directories.append(Path(options["cwd"]))
236
231
  _record_dirty_tree(configuration)
237
232
  if isinstance(configuration.claude_outcome, BaseException):
238
233
  raise configuration.claude_outcome
239
- assert isinstance(configuration.claude_outcome, ChainInvocationOutcome)
234
+ assert isinstance(configuration.claude_outcome, JobOutcome)
240
235
  return configuration.claude_outcome
241
236
 
242
237
  return fake_claude
@@ -284,18 +279,13 @@ def _apply_seams(
284
279
  "review_claude_runner",
285
280
  _build_claude_seam(call_log, configuration),
286
281
  )
287
- monkeypatch.setattr(
288
- chain_runner,
289
- "chain_subprocess_runner",
290
- _build_subprocess_seam(call_log),
291
- )
292
282
 
293
283
 
294
284
  def install_seams(
295
285
  monkeypatch: pytest.MonkeyPatch,
296
286
  *,
297
287
  host_profile: str = HOST_PROFILE_CLAUDE,
298
- claude_outcome: ChainInvocationOutcome | BaseException | None = None,
288
+ claude_outcome: JobOutcome | BaseException | None = None,
299
289
  should_dirty_tree_on_chain: bool = False,
300
290
  working_directory: Path | None = None,
301
291
  ) -> SeamCallLog: